TCP Socket的基础实现

本篇主线

环节 要解决的问题 结论
差异盘点 TCP 比 UDP 多做什么 多 listen / accept(服务端)、connect(客户端),读写接口反而变简单
★ 认知一 listen 到底在干什么 把套接字设成随时等人来连的状态:「餐厅老板必须守在店里」
★★ 认知二 为什么没调 accept 也能连上 三次握手由内核完成,与 accept 无关 ;accept 只是从内核里取走一条已建好的连接
★★ 认知三 accept 为什么要返回新的 fd 监听 fd 只负责「拉客」,新 fd 才负责「服务」------两个 fd 是两种角色
建立连接 谁在做三次握手 内核。listen 一设好,连接就能建立,只是没人接管
服务实现 服务端怎么写 Init() 三步 + Run() 里 accept → service + service 里 read → 判三态 → write
★ 读的语义 TCP 的 read 怎么判结束 返回 0 = 对端关闭连接,这是客户端退出的唯一信号
客户端 要不要 bind / listen 都不要;connect 时内核自动选本地 IP 与随机端口
★ 单进程死穴 为什么只能服务一个人 service 里是死循环,再没回到 accept

一、TCP 与 UDP:接口的差别其实很小

UDP 是面向数据报、无连接 的通信方式:套接字建好,直接发就行。

TCP 是面向连接的:通信之前,必须先与对端建立连接。

「要建立连接」这一条要求,直接推出了 TCP 独有的三个动作:

步骤 UDP TCP 差别
创建 socket(AF_INET, SOCK_DGRAM, 0) socket(AF_INET, SOCK_STREAM, 0) ★ 只差这一个枚举
绑定 bind bind 一字不改
设为可被动连接 ✖ 没有 ✔ listen TCP 独有
拿一条连接 ✖ 没有 ✔ accept TCP 独有,返回新 fd
主动建立连接 ✖ 没有 ✔ connect 客户端独有
收 recvfrom(要带地址) read / recv(不带地址) 参数更少
发 sendto(要带地址) write / send(不带地址) 参数更少

💡 一句话概括:TCP 在「连接管理」上多了三个接口,在「数据收发」上少了两组参数。收发之所以能简化,是因为连接一旦建立,对端就是唯一的、内核早就记好了四元组------地址参数成了废话。

二、工程骨架:把公共约定抽出来

2.1 文件结构

文件 职责
common.hpp 退出码枚举 + NoCopy 基类 + 公共头文件
tcp_server.hpp TcpServer 类声明
tcp_server.cc TcpServer 实现(服务端做封装)
tcp_client.cc 客户端(不做封装,面向过程直接写)
Makefile 一次产出 tcp_server 与 tcp_client 两个可执行文件

2.2 退出码:不硬编码

上一版 UDP 代码退出时直接 exit(1)、exit(2) 硬编码数字。这种写法有两个问题:

问题 后果
数字本身没有语义 三个月后回看,exit(3) 是哪一步失败?得翻代码
分散在各处 想加一个退出原因,得先全文搜一遍有没有撞号

改成集中管理的枚举:

cpp 复制代码
// common.hpp
enum class ExitCode
{
    OK = 0,        // 正常
    USAGE_ERR,     // 命令行参数用错
    SOCKET_ERR,    // 创建套接字失败
    BIND_ERR,      // 绑定失败
    LISTEN_ERR,    // 设为监听失败
    ACCEPT_ERR,    // 获取连接失败(非致命)
    CONNECT_ERR    // 客户端发起连接失败
};
cpp 复制代码
exit(static_cast<int>(ExitCode::BIND_ERR));

💡 起始值给 0、后面依次排开,是刻意为之:枚举默认从 0 递增,往后每加一项自动拿下一个值,不需要手工维护序号。这和日志等级用常量而不用数字是同一个思路。

于是程序退出后,只看退出码就知道卡在哪一步:

bash 复制代码
./tcp_server 8080 ; echo "exit=$?"

2.3 NoCopy:把「禁止拷贝」变成可复用的能力

服务器的语义决定了它不该被拷贝------从一个服务器对象复制出另一个服务器对象,没有任何意义,而且几乎必然是 bug。

最直接的做法是把拷贝构造和赋值在类里设成 private。但它不优雅:

写法 问题
在 TcpServer 里 private 掉 TCP 服务器不能拷了,但 UDP 服务器也想禁止拷贝,就得再抄一遍
抽成基类 NoCopy 谁想禁止,public NoCopy 继承一下即可
cpp 复制代码
// common.hpp
class NoCopy
{
public:
    NoCopy() = default;
    ~NoCopy() = default;
    NoCopy(const NoCopy&) = delete;              // 拷贝构造删掉
    NoCopy& operator=(const NoCopy&) = delete;   // 拷贝赋值删掉
};
cpp 复制代码
class TcpServer : public NoCopy
{
    // ...
};

★ 继承为什么就管用:派生类要生成自己的拷贝构造,必须先把基类拷贝一份 ;而基类的拷贝构造已经被 delete 掉了,这一步根本做不到,于是派生类的拷贝构造被编译器自动删除。一行继承,整条继承链都禁止拷贝。

2.4 服务端封装、客户端不封装

角色 是否封装 原因
服务端 ✔ 封装成 TcpServer 类 有状态(listen fd、端口、运行标志)、生命周期长、要反复复用
客户端 ✖ 面向过程直接写 流程线性(连上→发→收→退),封装收益低

三、socket():TCP 只差一个参数

c 复制代码
int socket(int domain, int type, int protocol);
参数 UDP 取值 TCP 取值 说明
domain AF_INET AF_INET 协议家族,底层都是 IPv4
type SOCK_DGRAM SOCK_STREAM ★ 唯一的差别
protocol 0 0 家族 + 类型已经唯一确定协议,写 0 即可

SOCK_STREAM 这个类型的四个定语值得记住:

定语 含义
有序 字节按发送顺序到达,不乱序
可靠 丢包会重传,不会静默丢失
全双工 同一条连接两个方向可以同时发数据
面向连接 必须先建连才能收发

3.1 socket 就是「打开一个网络文件」

步骤 内核实际做的事
① 创建一个 struct file 对象来描述这个网络文件
② 在进程的文件描述符表 里找一个没有使用的最小下标
③ 把这个下标返回给调用方

于是有一个可以直接验证的推论:

事实 结论
进程启动时 0 / 1 / 2 已被标准输入、输出、错误占用 第一个打开的 socket 一定是 3

运行日志里打出来的 listen sockfd: 3,就是这么来的。

💡 「socket 的文件怎么和网络关联起来」这件事本篇不展开,但可以先记住这个态度:它就是一个文件描述符 ,凡是文件能做的事------read / write / close------对它都能做。后面 TCP 的收发之所以直接用 read / write,根子就在这儿。


四、bind():复用封装好的地址类

上一版手写 sockaddr_in 要填一堆字段,还要 htons、inet_addr,很容易漏。既然之前已经封装了 InetAddr,这一步就可以直接复用:

cpp 复制代码
InetAddr local(_port);                                    // 只给端口
if (bind(_listensockfd, local.Addr(), local.Length()) < 0)  // 失败就是致命错误
{
    LOG(FATAL) << "bind failed";
    exit(static_cast<int>(ExitCode::BIND_ERR));
}
用到的成员 作用
InetAddr(port) 重载构造:只给端口时,sin_addr 填 INADDR_ANY
Addr() 返回 const struct sockaddr*(内部已经做过强转 ,外面不用再写 (struct sockaddr*))
Length() 返回 sizeof(struct sockaddr_in)

★ 这一步真正省掉的东西:不用再写 struct sockaddr_in addr; addr.sin_family = ...; addr.sin_port = htons(...); addr.sin_addr.s_addr = ...; 这一整套,也不用记 bind 的第二个参数要强转成 struct sockaddr*。

4.1 为什么用 INADDR_ANY 而不是某个具体 IP

绑定方式 效果
绑 0.0.0.0(INADDR_ANY) 凡是发到本机任意一张网卡、任意一个 IP 的 8080 端口,全部收下
绑某个具体 IP 只有发到那个 IP 的才收,其他 IP 上的连接一律不理

服务器推荐前者:公网 IP、内网 IP、127.0.0.1 全都能进来,不用关心客户端从哪条路摸过来。

4.2 端口号的选择

端口范围 说明
0 ~ 1023 知名端口,需要超级用户权限 ,普通用户 bind 直接失败
1024 ~ 65535 普通用户可以绑定,服务器一般选这区间

实测:绑 1000 失败、绑 1023 失败、绑 1024 成功。


五、★ listen():服务器从「关着门」变成「开着门」

5.1 一个类比先建立直觉

场景 要不要一直守着
寄信 / 快递 / 邮件 不用。东西放那儿,闲的时候自己去取就行
开餐厅 必须。老板每天得守在店里,因为随时可能有客人上门
  • 「放那儿自己去取」→ UDP,面向数据报,无连接,来了就扔在缓冲区里
  • 「老板必须守在店里」→ TCP,面向连接,必须随时准备好接客

所以 TCP 服务器初始化时,必须主动把自己的状态设成「监听」 ,等别人来连。UDP 没有这一步------建好套接字直接 recvfrom 就行。

5.2 接口与 backlog

c 复制代码
int listen(int sockfd, int backlog);
参数 说明
sockfd 已经 bind 好的监听套接字
backlog 底层允许排队的连接数上限

backlog 的取值有讲究:

取值 后果
不设 / 给 0 连接一多就被拒,实际等于没队列
几万、十万 不行,内核资源会吃紧,且没有意义
成熟软件的做法 Redis 之类通常给 32 / 16 这种量级
教学 / 低配机器 给 8 就够观察现象
cpp 复制代码
const static int DefaultBacklog = 8;
if (listen(_listensockfd, DefaultBacklog) < 0)
{
    LOG(FATAL) << "listen failed";
    exit(static_cast<int>(ExitCode::LISTEN_ERR));
}

5.3 实测:只 listen,什么都不做

把 Init() 里 socket → bind → listen 三步写完,Run() 里先放一个空循环(什么都不干),编译运行:

复制代码
$ ./tcp_server 8080
create socket success, sockfd: 3
bind success
listen success

另开一个终端查:

复制代码
$ netstat -nltp
Proto Local Address      State    PID/Program name
tcp   0.0.0.0:8080       LISTEN   108682/./tcpserver

★ 关键就在 LISTEN 这个状态上:代码里一个业务逻辑都没有,但服务器已经准备好接客了。


六、★★ 结论一:只要处于 listen,就已经可以被连接了

6.1 用 telnet 直接连

telnet 是一个「远程登录指定 TCP 服务」的工具,它不关心对面是 HTTP 还是自定义协议------只要是 TCP 就能连。

复制代码
$ telnet 127.0.0.1 8080
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.

代码里连 accept 都还没写,Connected 已经出现了。

💡 telnet 还能当简易 HTTP 客户端用,这件事本身就说明「网页就是跑在 TCP 上的字节流」:

复制代码
$ telnet www.baidu.com 80
GET / HTTP/1.1
Host: www.baidu.com
(空行再回车)

粘贴回来的就是一整页 HTML。之所以要先有 GET 和 Host 这个格式,是因为 HTTP 只是 TCP 上面的一层约定------协议是上层的事,TCP 只负责把字节可靠地送过去。

6.2 连接建立后 netstat 里有什么

复制代码
$ netstat -natp | grep 8080
Proto Local Address        Foreign Address      State        PID/Program name
tcp   0.0.0.0:8080         0.0.0.0:*            LISTEN       108682/./tcpserver
tcp   127.0.0.1:58752      127.0.0.1:8080       ESTABLISHED  108746/telnet
tcp   127.0.0.1:8080       127.0.0.1:58752      ESTABLISHED  -

ESTABLISHED 就是「连接已建立」。★ 一条客户端、一条服务端,两条记录的四元组正好互为镜像。

6.3 为什么是两条而不是一条

原因 说明
a. TCP 是全双工 每条连接必然有两个方向相反的通路,两端各有各的 socket
b. 客户端和服务端在同一台机器上 ★ 主要原因。两个 socket 都在这台机器上,所以两条记录都能被 netstat 看到

如果服务端部署在另一台机器上、客户端在本机,那么本机只能看到自己这一侧的那一条。

6.4 结论是怎么得出来的

观察 推论
没写 accept,连接照样变成 ESTABLISHED 建立连接这件事不需要 accept 参与
连接的信息在 netstat 里查得到,而进程没提供过 这些连接信息存在内核里
调 accept 时能拿到这条连接 所以 accept 的动作是从内核里取,不是「建立」

★ 一个足够强的表述:accept 获取的链接,是从内核中直接获取的;建立连接的过程和 accept 无关。

这张图把整件事串起来了:三次握手全在内核 里完成,完成的连接排进全连接队列 ;accept 的工作只是伸头进队列,取走一条已经是 ESTABLISHED 的记录。

动作 谁在做 与 accept 的关系
收到 SYN、回 SYN+ACK、收 ACK 内核 无关
把完成的连接放进全连接队列 内核 无关
从队列头取出一条,返回新 fd 进程调 accept 就是它

由此还能解释两个现象:

现象 原因
不调 accept,连接也能建成功 建连在内核,进程不参与
accept 调用得慢一点也没事 期间到达的连接会在队列里等着,直到队列满
accept 很快也没用 队列空时它只能阻塞等待,快不过内核建连的速度

七、★ accept():拿到的是一条「新的」连接

c 复制代码
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数 方向 说明
sockfd 入 监听套接字
addr 出 输出对端(客户端)的地址信息
addrlen 入 + 出 传入缓冲区大小,返回实际写入的长度
返回值 --- 成功返回一个新的文件描述符 ;失败返回 -1 并设置 errno

后两个参数的作用,和 UDP 里 recvfrom 的后两个参数完全对应:都是在回答「这条消息 / 这条连接是谁发来的」。

7.1 addr / addrlen 什么时候可以不传

需求 写法
想知道是谁连上来的 传 &peer 和 &len
不关心对端是谁 两个都传 NULL

7.2 复用 InetAddr 解析对端地址

accept 输出的是原始 sockaddr_in,但既然 InetAddr 已经能「把网络地址传进来 → 直接拿 IP / 端口」,那就再构造一个即可:

cpp 复制代码
struct sockaddr_in peer;
socklen_t len = sizeof(peer);

int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);
if (sockfd < 0)
{
    LOG(WARNING) << "accept failed";   // 注意:不是致命错误
    continue;                          // 直接去拿下一个
}

InetAddr client(peer);   // 用重载构造把 sockaddr_in 转成可读的 IP + 端口
LOG(INFO) << "get a new link: " << client.Ip() << ":" << client.Port();

★ 这里有个实测踩过的坑:addrlen 必须传 sizeof(struct sockaddr_in) ,而不是 sizeof(sockaddr)。如果传错,打印出来的 IP 会变成类似 253.127.0.0 这种明显不对的值------原因:accept知道"目标缓冲区"到底有多大,也就是ip的大小:16字节,你如果直接把整个sockaddr传进去,大小跟实际给的指针不对应,系统会误以为有其他数据,会给出错误的数字。

7.3 ★ accept 失败不是致命错误

对比两类失败:

失败点 性质 处理
socket / bind / listen 失败 致命。服务器根本起不来 打日志 + exit
accept 失败 非致命。只是这一次没拿到 打日志 + continue 继续下一轮

用鱼庄的话说:张三上去招呼,客人说「刚吃过」扭头就走。张三不会因此怀疑人生、更不会辞职不干------他转头去招呼下一个人。


八、好再来鱼庄:一次讲透两个 fd

accept 的返回值是一个新的文件描述符,这是 TCP 和 UDP 差别最大的地方。UDP 从头到尾就一个 fd,TCP 却会越用越多。为什么?

8.1 故事

杭州西湖边上一条街,全是饭庄、鱼庄,卷到每家门口都站一个人招呼客人 。只看其中一家------好再来鱼庄 。门口站的那位叫张三。

情节 对应
好再来鱼庄 代码里那个服务器进程
门口那条马路、街上的人流 操作系统内部
张三站在门口招呼 服务器处于 listen 状态
张三把客人领进门(拉客) accept 这个动作
张三朝里喊「来客人了,来个服务员」 accept 返回一个新的 fd
从后厨出来的李四 这个新 fd,专门服务这一桌客人
倒茶、拿菜单、点菜、上菜 读写,即 IO 服务
张三不进包间,继续回门口拉客 监听 fd 继续 accept 下一条
王五、赵六陆续出来 每来一个客人,多一个新的 fd

8.2 两个 fd 的分工

监听 fd(张三) 连接 fd(李四 / 王五 / 赵六)
怎么来的 socket() + bind() + listen() accept() 的返回值
有几个 整个进程只有一个 每来一个客户端就多一个
生命周期 与进程同寿 服务完这个客户端就 close
干什么 ★ 只负责获取链接 ★ 只负责读写
能不能收数据 ✖ 永远不用来收发数据 ✔ 全部业务都在它上面

8.3 命名要改

既然职责完全不同,代码里就不该都叫 sockfd。改名:

cpp 复制代码
int _listensockfd;   // 监听套接字,只负责拿连接

accept 返回的那个,在函数里叫 sockfd(或 connfd)------它是给这一个客户端提供服务的。

💡 「监听套接字」这个术语就是这么来的:它只监听,不服务 。把这两者搞混,写出来的代码一定会出问题------比如拿 _listensockfd 去 read,读到的永远是空,因为那条 fd 上根本没有数据流。


九、服务端实现:初始化三步 + 一个服务循环

9.1 类结构

cpp 复制代码
class TcpServer : public NoCopy
{
public:
    TcpServer(uint16_t port) : _port(port), _listensockfd(DefaultSockfd) {}
    ~TcpServer() {}
    void Init();          // 创建 + 绑定 + 监听
    void Run();           // 死循环:拿连接 → 服务
private:
    void Service(int sockfd);   // 处理一个客户端
private:
    uint16_t _port;
    int      _listensockfd;
    bool     _isrunning;
};

DefaultSockfd 是个常量 -1------fd 的初值统一给 -1 ,这样万一忘了初始化,read(-1, ...) 会立刻报错,而不是误用一个合法 fd。

9.2 Init():三步,每步都是致命错误

cpp 复制代码
void Init()
{
    // ① 创建套接字
    _listensockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (_listensockfd < 0)
    {
        LOG(FATAL) << "create socket failed";
        exit(static_cast<int>(ExitCode::SOCKET_ERR));
    }
    LOG(INFO) << "create socket success, sockfd: " << _listensockfd;

    // ② 绑定端口
    InetAddr local(_port);
    if (bind(_listensockfd, local.Addr(), local.Length()) < 0)
    {
        LOG(FATAL) << "bind failed";
        exit(static_cast<int>(ExitCode::BIND_ERR));
    }
    LOG(INFO) << "bind success";

    // ③ 设为监听(TCP 独有)
    if (listen(_listensockfd, DefaultBacklog) < 0)
    {
        LOG(FATAL) << "listen failed";
        exit(static_cast<int>(ExitCode::LISTEN_ERR));
    }
    LOG(INFO) << "listen success";
}

9.3 Run():拿一条、服务一条

cpp 复制代码
void Run()
{
    _isrunning = true;
    while (_isrunning)
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);

        int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);
        if (sockfd < 0)
        {
            LOG(WARNING) << "accept failed";
            continue;                  // 非致命,直接下一个
        }

        InetAddr client(peer);
        LOG(INFO) << "get a new link: " << client.Ip() << ":" << client.Port();

        Service(sockfd);               // ★ 这里是单进程版的死穴所在
    }
    _isrunning = false;
}

9.4 Service():读 → 判断 → 写回

cpp 复制代码
void Service(int sockfd)
{
    char buffer[1024];

    while (true)
    {
        ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
        if (n > 0)
        {
            buffer[n] = '\0';
            LOG(DEBUG) << "client say: " << buffer;

            std::string echo = "echo# ";
            echo += buffer;
            write(sockfd, echo.c_str(), echo.size());
        }
        else if (n == 0)
        {
            LOG(INFO) << "client quit";
            close(sockfd);
            break;
        }
        else
        {
            LOG(WARNING) << "read error";
            close(sockfd);
            break;
        }
    }
}

💡 这里用 sizeof(buffer) - 1 而不是 sizeof(buffer),是为了给 '\0' 留一个字节 。若恰好读满 1024 字节,buffer[1024] = '\0' 就越界了------这是 C 字符串处理最经典的差一错误。


十、★ read 的三种返回值

TCP 面向字节流,所以读 socket 用的就是读普通文件的接口:

c 复制代码
ssize_t read(int fd, void *buf, size_t count);
返回值 含义 服务端该做什么
n > 0 读到了 n 个字节 正常处理业务
n == 0 ★ 对端把连接关了 close(sockfd) 后 break
n < 0 读取出错(fd 非法 / 已关闭 / 连接异常) close(sockfd) 后跳出

10.1 n 可能小于期望值

count 是「期望最多读这么多」,不是「一定读到这么多」。

概念 说明
期望读 1023 字节 传进去的 count
实际读到 1000 字节 对端这次只发了这么多,返回值就是 1000
上限保证 返回值一定小于等于 count,不会溢出缓冲区

★ 这一条正是「面向字节流」的直接后果:TCP 没有消息边界 ,一次 read 读到多少完全由内核缓冲区里此刻有多少决定。想按「一条消息」处理,得自己在上层做切分。

10.2 n == 0 为什么等于「对端关闭」

这跟管道一模一样:

场景 读端行为
管道写端暂时不写 读端阻塞等待
管道写端关闭 读端读到 0,即文件结尾
socket 对端暂时不发 read 阻塞等待
socket 对端 close read 返回 0

★ 所以对服务端来说,read 返回 0 是「客户端退出了」的唯一信号 。收到它就必须把这条连接 fd close 掉------客户端已经关了自己那一端,服务端不关,这个 fd 就一直泄漏着。


十一、客户端实现:socket → connect → write → read → close

11.1 客户端不需要做的事

动作 要不要 原因
bind(显式) ✖ 不需要 但底层必须绑 ------connect 成功时内核会自动 选一个本地 IP 和随机端口绑上
listen ✖ 不需要 那是服务器的活:服务器要设成监听态等着别人来连;客户端是主动去连别人的
accept ✖ 不需要 客户端不获取连接,它发起连接

★ 「客户端不需要 bind」这句话的准确含义是:不需要显式 bind,但绑定这件事一定会发生,只是交给了内核在 connect 时顺手做掉 。这也解释了为什么 netstat 里能看到客户端用了一个随机的高位端口(如 55880)。

11.2 connect()

c 复制代码
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数 方向 说明
sockfd 入 自己刚创建的套接字
addr 入 ★ 目标服务器的地址(不是自己的)
addrlen 入 该地址结构的长度
返回值 --- 成功 0,失败 -1

后两个参数的含义,和 UDP 里 sendto 的后两个参数完全对应 :sendto 是「要把数据发给谁」,connect 是「要把连接发给谁」。

connect 一调用,内核就开始做三次握手。握手完成则返回 0,同时顺手完成本地绑定。

11.3 完整客户端

cpp 复制代码
void Usage(const char* proc)
{
    std::cout << "Usage: " << proc << " server_ip server_port" << std::endl;
}

int main(int argc, char* argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(static_cast<int>(ExitCode::USAGE_ERR));
    }

    std::string serverip = argv[1];
    uint16_t    serverport = std::stoi(argv[2]);

    // ① 创建套接字(与服务器完全一样,只差一个枚举)
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        LOG(FATAL) << "create socket failed";
        exit(static_cast<int>(ExitCode::SOCKET_ERR));
    }

    // ② 发起连接(不需要 bind,也不需要 listen)
    InetAddr server(serverip, serverport);
    if (connect(sockfd, server.Addr(), server.Length()) < 0)
    {
        LOG(FATAL) << "connect failed";
        exit(static_cast<int>(ExitCode::CONNECT_ERR));
    }
    LOG(INFO) << "connect success";

    // ③ 发一条、收一条
    while (true)
    {
        std::string line;
        std::cout << "Please Enter# ";
        std::getline(std::cin, line);
        if (line.empty()) continue;

        write(sockfd, line.c_str(), line.size());

        char buffer[1024];
        ssize_t s = read(sockfd, buffer, sizeof(buffer) - 1);
        if (s > 0)
        {
            buffer[s] = '\0';
            std::cout << "server echo# " << buffer << std::endl;
        }
    }

    close(sockfd);   // 用完了就关,和关普通文件一模一样
    return 0;
}

11.4 argc 的校验

期望 argc
./tcp_client 127.0.0.1 8080 3(程序名 + IP + 端口)

不等于 3 就输出用法提示并退出------服务端只要 2 个(程序名 + 端口),因为它自己就是服务,不需要知道谁来连。


十二、双端完整时序对照

步骤 服务端 客户端
1 socket() → 拿到 listen fd socket() → 拿到自己的 fd
2 bind() → 绑端口 ---(不需要显式绑)
3 ★ listen() → 从此刻起就能被连接 ---(不需要)
4 accept() → 阻塞等,直到队列里有货 ★ connect() → 触发三次握手
5 拿到 connfd,打日志 连接成功,可以开始发
6 read(connfd) → 阻塞等数据 write(fd) → 发出请求
7 write(connfd) → 写回响应 read(fd) → 阻塞等回声
8 close(connfd) → 回到 ④ close(fd) → 退出

💡 两边节奏完全不耦合:服务端要先跑起来(先 listen),客户端随时可以 connect;客户端发一条,服务端回一条,各自阻塞在自己的读上------这就是「全双工 + 阻塞 IO」最朴素的样子。


十三、实测现象与 netstat 用法

13.1 netstat 常用参数

参数 含义
-n 显示成数字(127.0.0.1:8080),不做反解
-a 显示所有(含监听态)
-t 只看 TCP
-u 只看 UDP
-l 只看处于 LISTEN 的
-p 显示进程信息(PID / 程序名)

组合使用:

命令 用途
netstat -nltp 只看「谁在监听」,最常用
netstat -natp 看所有 TCP,包括已建立的连接

13.2 一次完整实测

复制代码
# 终端 1:起服务
$ ./tcp_server 8080
create socket success, sockfd: 3
bind success
listen success

# 终端 2:起客户端
$ ./tcp_client 127.0.0.1 8080
connect success
Please Enter# 你好
server echo# echo# 你好

服务端同时打出:

复制代码
get a new link: 127.0.0.1:55880
client say: 你好

55880 就是客户端内核挑的随机端口。再查 netstat -natp,会看到三条记录:一条 LISTEN、两条互为镜像的 ESTABLISHED。

客户端 Ctrl+C 杀掉后:

复制代码
client quit

服务端读到 0,close(connfd) 退出 Service,回到 accept 等下一位。


十四、★ 单进程版的死穴

Run() 里的 Service(sockfd) 是同步调用 ,而 Service 内部是个 while (true)。于是主线程的执行路径是:

复制代码
accept() → 拿到 conn1 → Service(conn1) → while(1){ read ... }
                                          ↑ 第一个客户不说话,就一直卡在 read
现象 原因
第二个客户端 connect 成功 连接由内核建立,与 accept 无关
但它发消息没有任何回应 没人 read 它那条 fd
服务端的日志里也没有它 因为根本没走到 accept

也就是:后面的客户端能被连上,但没人招呼。 用鱼庄的话说,张三拉进第一个客人后跟着进包间端盘子去了,门口没人了;新客人照样能推门进来,但是干站着。

这种服务器的定位 说明
只是功能验证版 用来确认接口调通了、数据能通
不可能上线 一个客户端连着不说话,整个服务就死了

下一节要解决的就是这件事:把「拉客」和「服务」拆到不同的执行流上------多进程、多线程,或者线程池。


十五、速查表

15.1 服务端

步骤 代码
创建 socket(AF_INET, SOCK_STREAM, 0)
绑定 bind(fd, local.Addr(), local.Length())
★ 监听 listen(fd, backlog),backlog 给 8~32
★ 取连接 accept(listenfd, (struct sockaddr*)&peer, &len),len = sizeof(peer)
读 read(connfd, buf, sizeof(buf) - 1)
写 write(connfd, s.c_str(), s.size())
关闭 close(connfd)

15.2 客户端

步骤 代码
创建 socket(AF_INET, SOCK_STREAM, 0)
绑定 ✖ 不需要(connect 时内核自动绑)
监听 ✖ 不需要
★ 连接 connect(fd, server.Addr(), server.Length())
发 / 收 write / read(与文件相同)
关闭 close(fd)

15.3 一次性对照

项目 UDP TCP
套接字类型 SOCK_DGRAM SOCK_STREAM
设监听 ✖ ✔ listen
拿连接 ✖ ✔ accept(返回新 fd)
主动连接 ✖ ✔ connect(客户端)
收发接口 recvfrom / sendto(带地址) read / write(不带地址)
fd 数量 固定 1 个 1 个监听 + N 个连接
消息边界 ✔ 有(一个数据报一条) ✖ 无(字节流)
相关推荐
C++ 老炮儿的技术栈1 小时前
Linux 动态库踩坑:明明重新编译了 .so,调用函数返回值却一直不变(libfjrobot.so 版本号异常排查实录)
linux·c语言·网络·c++·visual studio
川石课堂软件测试10 小时前
学习软件测试需掌握的一些知识
数据库·python·sql·功能测试·网络协议·mysql·http
车软学堂11 小时前
从测试转开发,到底要补什么
网络·人工智能·python·功能测试·单片机·车载系统·自动化
zl_dfq12 小时前
Windows 下 TCP 套接字编程指南::从 Winsock 初始化到回显服务器
tcp/ip
明天也要努力学习12 小时前
IP 协议的相关特性
网络·网络协议·tcp/ip
说实话起个名字真难啊12 小时前
一次中风险漏洞处理复盘:从RSA密钥交换到全面启用TLSv1.3
网络
L02082413 小时前
Kubernetes Ingress|CNI 网络模型|Calico 网络策略|Pod 调度器完整实战教程
运维·网络·云原生·容器·kubernetes
启博网络13 小时前
下一代防火墙是什么意思?下一代防火墙可以抵御哪些网络攻击?
网络·防火墙