learn.ochresign
阶段 0 · 补前置

socket API 与文件描述符

socket、bind、listen、accept、read、write、close 这七个系统调用,以及“一切皆文件”的 fd。

fd:一个整数编号

在 Linux 里,打开的文件、管道和网络连接都用同一种东西表示:文件描述符(file descriptor,简称 fd),它就是一个小整数。0、1、2 分别是标准输入、标准输出和标准错误,你新建的 socket 通常从 3 开始编号。

对 fd 调用 read / write 就是收发数据,用完必须 close,否则 fd 会泄漏。每个进程能打开的 fd 数量有上限(ulimit -n 查看,默认往往只有 1024),压测上万个连接之前要先调大它。

服务器端的七步

步骤 调用 做了什么
1 socket(AF_INET, SOCK_STREAM, 0) 创建一个 TCP socket,返回 fd
2 bind(fd, addr, len) 绑定到某个 IP 和端口
3 listen(fd, backlog) 变成监听 socket。backlog 是已完成握手、等待 accept 的队列长度
4 accept(fd, ...) 从队列中取出一条连接,返回一个新的 fd,专门用于和这个客户端通信
5 read(conn_fd, buf, n) 收数据。返回读到的字节数;返回 0 表示对方关闭了连接
6 write(conn_fd, buf, n) 发数据。返回实际写入的字节数,可能小于 n
7 close(fd) 关闭连接,释放 fd

一个容易混淆的点:监听 fd 只负责接客,永远不用来收发数据;每个客户端都有自己专属的连接 fd。

返回值一定要检查

ssize_t n = read(conn_fd, buf, sizeof(buf));
if (n > 0) {
    // 读到了 n 个字节
} else if (n == 0) {
    // 对方关闭了连接(收到 FIN),应该 close(conn_fd)
} else if (errno == EINTR) {
    // 被信号打断了,重试即可
} else if (errno == EAGAIN || errno == EWOULDBLOCK) {
    // 非阻塞 fd 上暂时没有数据,下次再读(第二课会用到)
} else {
    // 真正的错误,比如 ECONNRESET(对方异常断开)
}

write 同理。阻塞模式下,write 一般会等到全部写完才返回;非阻塞模式下可能只写了一部分。阶段 1 的输出缓冲区就是为此而设计的。

客户端只有三步

int fd = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
connect(fd, (sockaddr*)&addr, sizeof(addr));   // 三次握手在这里发生
write(fd, "hi", 2);

平时测试不用自己写客户端,nc 127.0.0.1 8888 就是一个现成的 TCP 客户端。

SIGPIPE:服务器莫名其妙退出

向一个已经被对方关闭的连接 write,内核会给进程发送 SIGPIPE 信号,而它的默认行为是直接终止进程。服务器程序一定要在启动时忽略这个信号:

#include <csignal>
signal(SIGPIPE, SIG_IGN);   // 忽略后,write 会返回 -1,errno 为 EPIPE

自测

accept 返回的 fd 和监听 fd 是同一个吗?

不是。accept 返回一个新的 fd,代表这一条连接。监听 fd 继续留着接受其他连接。

write(fd, buf, 100) 返回了 60,剩下的 40 个字节怎么办?

要自己记下来,等 fd 可写之后继续写剩下的部分。非阻塞服务器的做法是:把剩下的数据放进这条连接的输出缓冲区,并关注 EPOLLOUT 事件。

压测时报 Too many open files 是怎么回事?

fd 数量超过了进程上限。先用 ulimit -n 65535 调大限制,再检查是否有 fd 泄漏,比如断开的连接没有 close。

面试常问

listen 的 backlog 参数是什么意思?

参考回答: 内核为每个监听 socket 维护两个队列:半连接队列(收到 SYN、还没完成握手)和全连接队列(握手完成、等着被 accept 取走)。在现在的 Linux 上,backlog 限制的是全连接队列的长度,实际值是 backlog 和 net.core.somaxconn 中较小的那个。服务器 accept 不及时导致全连接队列满了,新的握手就会被丢弃,客户端表现为连接超时,或者连上了却迟迟收不到回复。半连接队列的大小由 tcp_max_syn_backlog 控制,SYN Flood 攻击打的就是它,防御手段是 SYN Cookie。

accept 发生在三次握手的哪一步?connect 什么时候返回?

参考回答: accept 不参与握手。三次握手完全由内核完成,完成后连接进入全连接队列,accept 只是从队列里取出一个已经建立好的连接。所以即使服务器从不调用 accept,客户端的 connect 也能成功返回。客户端这边,connect 在收到服务器的 SYN+ACK、发出最后的 ACK 之后就返回了。

close 和 shutdown 有什么区别?

参考回答: close 是把 fd 的引用计数减一,计数到 0 才真正关闭连接、发送 FIN,并且读写两个方向都关掉。shutdown 不管引用计数,直接作用于连接本身,还可以只关一个方向:shutdown(fd, SHUT_WR) 只关闭写,发出 FIN 告诉对方“我发完了”,但还能继续读对方发来的数据,这叫半关闭。网络库在主动断开时常用 shutdown 写端,等对方把数据收完、也关闭后再 close。

服务器往一个已经断开的连接 write,会发生什么?

参考回答: 对方已经关闭时,第一次 write 通常能成功写进内核缓冲区,但对方会回一个 RST;再写的话,进程会收到 SIGPIPE 信号,默认处理方式是直接终止进程,表现为服务器莫名其妙退出。所以服务器启动时要 signal(SIGPIPE, SIG_IGN) 忽略它,或者用 send 时带上 MSG_NOSIGNAL,这样 write 只会返回 -1、errno 为 EPIPE,按正常的出错流程关闭连接就行。

TCP_NODELAY 是干什么的?什么时候要打开?

参考回答: 它用来关闭 Nagle 算法。Nagle 算法会把小数据攒一攒再发,在还有数据没被确认时,新的小包要等 ACK 回来才发,能减少网络上的小包数量;但和对端的延迟确认叠加,会出现几十毫秒的延迟。Redis 这种请求响应都很小、又在意延迟的服务,一般都会打开 TCP_NODELAY。

原文与延伸

  • man 2 socket、man 2 accept、man 7 tcp:手册页是最准确的资料
  • Beej's Guide to Network Programming:最受欢迎的 socket 入门教程,作者允许免费在线阅读
  • 游双《Linux 高性能服务器编程》第 5 章