4、Socket编程基础(下):connect()、send()、recv()、close()函数、阻塞与非阻塞模式、I/O多路复用概念引入

好,咱们接着聊。上一节我们把 socket 的创建和绑定讲完了,相当于把电话机装好了,号码也分配了。这一节,咱们真正开始「打电话」和「传数据」。

我会把 connect、send、recv、close 这几个核心函数掰开揉碎讲清楚。然后重点聊聊阻塞与非阻塞——这块坑特别多。最后,我会用一个简单的例子,引出 I/O 多路复用这个概念。别急,一步步来。

4.1 connect():发起连接

客户端要连服务器,靠的就是 connect()。它的原型长这样:

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数很简单:你的 socket 描述符,服务器的地址结构体,以及地址长度。

connect() 干了什么事?说白了,就是发起一个三次握手。客户端发送 SYN,服务器回复 SYN+ACK,客户端再回 ACK。这个过程中,connect() 会一直等着,直到握手完成或者出错。

关键点:connect() 的返回值只在出错时才有意义。返回 0 表示成功,返回 -1 表示失败。但失败的原因千奇百怪,你得用 errno 去查。

我在项目中遇到过一个问题:客户端 connect 一个不存在的端口,结果卡了 30 秒才返回。为什么?因为内核在重试 SYN 包。默认超时时间很长,大概 20-30 秒。如果你写的是高并发服务,这个等待时间是不能忍的。

小技巧:把 socket 设为非阻塞模式,然后用 select/poll/epoll 去等 connect 完成。这样你可以控制超时时间,比如 3 秒没连上就直接放弃。

4.2 send() 和 recv():数据的收发

连接建立之后,就该传数据了。send() 和 recv() 是 TCP 下最常用的收发函数。

ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

send() 把 buf 里的数据发出去,recv() 从内核缓冲区里读数据。flags 一般填 0,特殊场景下会用 MSG_DONTWAIT 或 MSG_PEEK。

嗯,这里要注意:send() 返回的是实际发送的字节数,不一定等于你要发的 len。为什么?因为内核缓冲区可能满了。你想想看,如果对方收得慢,你的发送缓冲区就会积压。send() 能发多少就发多少,剩下的你得自己再发一次。

recv() 也一样。它返回的是实际读取的字节数。如果返回 0,说明对方已经关闭了连接。这是 TCP 里判断对端断开的标准方式。

我曾经踩过的坑:写了一个 HTTP 客户端,用 recv() 一次读 4096 字节,以为一次就能读完整个响应。结果响应体超过 4096 字节,程序只处理了前半段。后来我加了个循环,一直读到 recv() 返回 0 或者超时,才把完整响应拼出来。

所以,记住一个原则:永远不要假设一次 send/recv 就能搞定所有数据。写网络程序,循环收发是基本功。

4.3 close():关闭连接

数据传完了,就该关连接了。close() 很简单:

int close(int fd);

但它的行为比你想象的要复杂。close() 只是把 socket 的引用计数减 1。如果还有其他进程或线程在引用这个 socket,连接不会真正关闭。

更关键的是,close() 会立即返回,但内核可能还在发送缓冲区里的数据。如果你紧接着又 fork 了一个子进程,子进程可能会继承这个 socket,导致连接意外保持。

最佳实践:在 close() 之前,先调用 shutdown()。shutdown() 可以单独关闭读端或写端,而且它会立即发送 FIN 包,告诉对端「我不再发了」。这样对端的 recv() 会返回 0,双方都能优雅地关闭。

// 优雅关闭的典型流程
shutdown(sockfd, SHUT_WR);  // 关闭写端,发送 FIN
// 继续读,直到 recv() 返回 0
while (recv(sockfd, buf, sizeof(buf), 0) > 0) {
    // 处理剩余数据
}
close(sockfd);  // 真正关闭

4.4 阻塞模式 vs 非阻塞模式

默认情况下,socket 是阻塞的。什么意思?

  • connect() 会一直等到三次握手完成
  • send() 会一直等到数据被拷贝到内核缓冲区
  • recv() 会一直等到有数据可读

阻塞模式的好处是编程简单。你写一个循环,调 recv(),数据来了就处理,没来就等着。但坏处也很明显:一个线程只能处理一个连接。如果 1000 个客户端连上来,你得开 1000 个线程。线程切换的开销,你想想看,有多大?

非阻塞模式就不一样了。你把 socket 设为非阻塞:

// 设置非阻塞
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

之后,connect()、send()、recv() 会立即返回。如果操作不能立即完成,它们返回 -1,errno 设为 EAGAIN 或 EWOULDBLOCK。

非阻塞模式的好处是:你可以用一个线程管理成千上万个连接。但坏处是:你得自己轮询,不断检查哪个 socket 可读、哪个可写。这效率太低了。

我个人习惯:从来不用纯轮询的方式处理非阻塞 socket。那太原始了。我会用 I/O 多路复用,让内核告诉我哪些 socket 准备好了。

4.5 I/O 多路复用概念引入

好,现在问题来了。我们有 1000 个非阻塞 socket,每个都可能随时有数据到达。你怎么知道该读哪个?

一种办法是:每个 socket 都去调一次 recv(),如果返回 EAGAIN 就跳过。这叫轮询。但轮询的复杂度是 O(n),1000 个还好,10 万个呢?CPU 全浪费在系统调用上了。

另一种办法是:让内核帮你盯着。你把所有 socket 交给内核,告诉它:「有数据了通知我」。这就是 I/O 多路复用。

Linux 下有三种实现:select、poll、epoll。它们的核心思想是一样的:一次系统调用,监控多个文件描述符

我画了一张图,帮你理解这个流程:

多个客户端连接 socket 1 socket 2 socket 3 socket 4 ...... socket N I/O 多路复用 select / poll / epoll 一次调用,监控所有 就绪 socket 列表 socket 2(可读) socket 5(可写) 应用程序处理 只处理就绪的 socket

你看,左侧是 N 个 socket 连接,中间是 I/O 多路复用机制,右侧是内核返回的「就绪列表」。应用程序只需要处理那些真正有事件发生的 socket,不用一个个去问。

这样做的优势很明显:

  • 效率高:复杂度从 O(n) 降到 O(就绪数)
  • 资源省:一个线程就能管理上万个连接
  • 响应快:有事件立刻处理,不用空转

核心思想:I/O 多路复用的本质,是把「谁准备好了」这个问题的答案,从应用程序转移到了内核。内核更清楚每个 socket 的状态,让它来通知你,比你自己轮询高效得多。

select、poll、epoll 这三兄弟,我会在后面的章节详细讲。今天你只需要记住:I/O 多路复用是高性能网络服务器的基石。没有它,你写不出能支撑上万并发连接的程序。

好了,这一节的内容就到这。connect、send、recv、close 是基本功,阻塞与非阻塞是你要做的第一个选择,而 I/O 多路复用是你走向高性能的必经之路。把这些概念理清楚,后面的路就好走了。


公众号:蓝海资料掘金营,微信 deep3321