2.16 套接字基础
一个 socket 也是文件描述符,那它和普通文件差在哪?bind/listen/accept 每一步在内核里各做了什么?为什么 accept 返回的新 fd 不继承 O_NONBLOCK?TCP 与 UDP 在编程接口上到底哪些地方不同?
写出一个能处理 EINTR、短读短写、SIGPIPE 的最小 TCP echo 服务端与配套客户端;用 getaddrinfo 写出同时支持 IPv4/IPv6 的连接代码;解释 SO_REUSEADDR、backlog、TIME_WAIT 的关系;用 socketpair 做本机双向通信。
2.14 与 2.15 讲的是「同一台机器上的进程通信」。这一章把通信推到网络上:两个进程不再共享内核里的管道或共享内存,只能通过协议交换字节。编程接口仍然是 fd——socket() 返回一个文件描述符,read/write 照样能用——但语义变了:读写可能短、可能阻塞、可能被信号打断,对端可能在任何时刻消失。
TCP 提供「可靠、有序、双向字节流」,UDP 提供「不可靠、有边界的数据报」。两者在内核里是两套协议栈,在 API 上只差几个参数和几个函数。真正难的不是记住 socket/bind/listen/accept,而是理解 TCP 的字节流没有消息边界:你写两次 100 字节,对端可能一次读到 200 字节,也可能分十次读到 20 字节。这个事实决定了 2.17 的长度前缀协议,也决定了为什么「一次 read 就是一条消息」的代码在压测时必然出错。
这一章先打地基:地址结构、连接建立、最小可用的 TCP 服务端与客户端、UDP 数据报、UNIX domain socket。所有例子都用 getaddrinfo 而不是 inet_addr,因为它们要能在 IPv6-only 的环境里跑起来。
2.16.1 socket 是 fd,但不是一个「文件」
套接字是内核里的一个通信端点,由 (domain, type, protocol) 三元组创建:
- domain(地址族):
AF_INET(IPv4)、AF_INET6、AF_UNIX(本机); - type:
SOCK_STREAM(字节流,TCP/UNIX stream)、SOCK_DGRAM(数据报,UDP)、SOCK_SEQPACKET(有边界的有序报文,UNIX 支持)、SOCK_RAW; - protocol:通常填 0,由前两者决定。
socket() 返回一个 fd,进入进程的 fd 表,表项指向 struct file,后者指向 struct socket 与 struct sock。所以 read/write/close/fcntl/poll 都能用,但 lseek 会返回 ESPIPE。
「socket 也是 fd」带来三个直接好处:
- 统一的事件循环:
epoll同时监听 socket、管道、timerfd、signalfd(2.18); - 统一的继承规则:
fork后子进程共享同一个 socket(偏移概念不适用,但连接状态共享),exec时受FD_CLOEXEC控制; - 统一的权限模型:
/proc/<pid>/fd能看到 socket,close是唯一的释放方式。
差异也要记牢:
| 维度 | 普通文件 | socket |
|---|---|---|
lseek | 支持 | ESPIPE |
read 返回 0 | EOF(文件末尾) | 对端关闭写方向(收到 FIN) |
write 失败 | ENOSPC、EDQUOT | EPIPE(对端关闭)+ SIGPIPE |
| 短读/短写 | 少见(普通文件) | 常态 |
| 并发 | 多进程写同一文件需锁 | 内核保证字节流顺序,但消息边界要自己做 |
O_NONBLOCK | 对普通文件基本无效 | 决定 accept/connect/read 是否阻塞 |
SOCK_CLOEXEC 与 SOCK_NONBLOCK 可以在 socket() 的 type 里直接指定(Linux 2.6.27+),避免「创建后再 fcntl」的竞态:
int fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
2.16.2 地址结构:sockaddr 家族
所有 socket 地址结构都以 sa_family_t sa_family 开头,系统调用用 struct sockaddr * 加长度来传递,内核根据 family 决定怎么解释后面的字节。这种「通用头 + 具体结构」的设计让 bind/connect 能同时接受 IPv4、IPv6、UNIX 地址。
#include <netinet/in.h>
#include <sys/un.h>
struct sockaddr_in { /* IPv4 */
sa_family_t sin_family; /* AF_INET */
in_port_t sin_port; /* 网络字节序 */
struct in_addr sin_addr; /* 网络字节序 */
/* 8 字节 padding,历史遗留 */
};
struct sockaddr_in6 { /* IPv6 */
sa_family_t sin6_family; /* AF_INET6 */
in_port_t sin6_port;
uint32_t sin6_flowinfo;
struct in6_addr sin6_addr;
uint32_t sin6_scope_id; /* 链路本地地址的接口索引 */
};
struct sockaddr_un { /* UNIX domain */
sa_family_t sun_family; /* AF_UNIX */
char sun_path[108]; /* 路径或抽象名字 */
};
两个必须遵守的规则:
- 端口与地址用网络字节序。
sin_port = htons(8080),sin_addr用inet_pton或INADDR_ANY。直接把主机字节序的整数塞进去,在 x86 上会变成 0x1F90 → 0x901F(端口 36919),而且在小端机器上「看起来能跑」的代码到大端机器就错了。 - 传长度。
bind(fd, (struct sockaddr *)&addr, sizeof addr),不是sizeof(struct sockaddr)。内核用长度判断地址是否完整、是否需要截断。
IPv4 地址的两种写法:
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = htonl(INADDR_ANY); /* 0.0.0.0,所有网卡 */
/* 或者用文本转换,失败返回 0 */
if (inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) != 1) {
fprintf(stderr, "bad address\n");
}
inet_ntop 是反方向,输出用 INET_ADDRSTRLEN(16)或 INET6_ADDRSTRLEN(46)大小的缓冲区。不要用 inet_ntoa:它返回静态缓冲区,不可重入。
- 「
sin_port直接赋值端口号」——必须htons。字节序错了端口就变了,且只有跨架构才暴露。 - 「
sizeof(struct sockaddr)可以当长度」——它只有 16 字节,比sockaddr_in小;IPv6 的sockaddr_in6是 28 字节。必须传具体结构的大小。 - 「
sockaddr_un的路径随便填」——sun_path只有 108 字节,必须strncpy并保证结尾有\0(或使用抽象命名空间时不加\0)。 - 「
INADDR_ANY是 127.0.0.1」——是 0.0.0.0,绑定所有网卡。只监听本机要用127.0.0.1。
2.16.3 bind、listen 与两个连接队列
服务端的三步:socket → bind → listen。
bind把本地地址(IP + 端口)绑定到 socket。端口填 0 表示让内核分配临时端口,之后用getsockname取回。listen把 socket 从「主动」转为「被动」,第二个参数是 backlog:已完成三次握手、等待accept的连接队列上限。
listen() 的 backlog 只约束 accept 队列;SYN flood 打的是 SYN 队列(tcp_syncookies 是另一条防线)。ss -lnt 的 Recv-Q 就是 accept 队列里等待被取走的连接数。读图:客户端发 SYN,服务端回 SYN+ACK,这条连接先进入 SYN 队列(半连接队列);客户端回 ACK 后,连接移入 accept 队列(全连接队列),等待应用调用 accept 取走。两个队列各有上限:SYN 队列受 net.ipv4.tcp_max_syn_backlog 约束,accept 队列受 listen(fd, backlog) 与 net.core.somaxconn 的较小值约束。ss -lnt 的 Recv-Q 是当前 accept 队列长度,Send-Q 是 backlog 上限。
两个队列溢出时的行为不同:
- SYN 队列满:新的
SYN被丢弃(或启用 syncookie),客户端重传; - accept 队列满:服务端通常忽略客户端的
ACK,让客户端重传,表现为「连接建立慢」而不是「立即拒绝」。
这就是为什么「accept 慢」会导致客户端超时,而不是收到 ECONNREFUSED。ECONNREFUSED 只在端口没人监听、或防火墙回 RST 时出现。
int one = 1;
if (setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one) == -1) {
perror("setsockopt SO_REUSEADDR");
}
if (bind(lfd, (struct sockaddr *)&addr, sizeof addr) == -1) {
perror("bind");
}
if (listen(lfd, 128) == -1) {
perror("listen");
}
SO_REUSEADDR 的作用是允许 bind 一个处于 TIME_WAIT 的本地地址(以及在某些情况下绑定通配地址与具体地址的组合)。服务端几乎总要开它,否则重启时会因为旧连接的 TIME_WAIT 而 EADDRINUSE。它不是「允许多个进程绑同一端口」——那是 SO_REUSEPORT 的语义(2.17)。
2.16.4 accept 与 connect:阻塞、非阻塞与 EINPROGRESS
accept 从 accept 队列取一条已完成握手的连接,返回一个新的 fd。这个新 fd 与监听 fd 是两个不同的 socket:
- 监听 fd 继续
accept新连接; - 新 fd 代表这条连接,读写都在它上面;
- 新 fd 不继承监听 fd 的
O_NONBLOCK(Linux 行为)。监听 fd 是非阻塞的,accept返回的连接默认仍是阻塞的。要非阻塞,用accept4(..., SOCK_NONBLOCK | SOCK_CLOEXEC)。
struct sockaddr_storage peer;
socklen_t peer_len = sizeof peer;
int cfd = accept4(lfd, (struct sockaddr *)&peer, &peer_len,
SOCK_CLOEXEC | SOCK_NONBLOCK);
if (cfd == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
/* 非阻塞监听 fd:当前没有新连接 */
} else if (errno == EINTR) {
/* 被信号打断,重试 */
} else if (errno == EMFILE || errno == ENFILE) {
/* fd 耗尽:常见于 fd 泄漏或连接风暴 */
} else {
perror("accept4");
}
}
EMFILE 是服务端的经典故障:进程 fd 用完了,accept 返回 EMFILE,但连接仍在队列里,客户端看起来「连上了但不响应」。正确做法是预留一个空闲 fd,遇到 EMFILE 时关掉它、accept 一条连接、立刻 close,给日志留出空间,再重新打开预留 fd。
connect 的行为分两种:
- 阻塞 socket:
connect等三次握手完成才返回;失败返回-1,errno为ECONNREFUSED、ETIMEDOUT、ENETUNREACH等; - 非阻塞 socket:
connect通常立刻返回-1、errno = EINPROGRESS,连接在后台继续。用poll等POLLOUT,再getsockopt(SOL_SOCKET, SO_ERROR)取结果。
int rc = connect(fd, res->ai_addr, res->ai_addrlen);
if (rc == -1 && errno == EINPROGRESS) {
struct pollfd pfd = { .fd = fd, .events = POLLOUT };
if (poll(&pfd, 1, 5000) <= 0) { /* 超时或出错 */ }
int err = 0;
socklen_t len = sizeof err;
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len) == -1 || err != 0) {
/* 连接失败,err 是原因 */
}
}
一个容易忽略的点:阻塞 connect 被信号打断时返回 EINTR,此时连接可能仍在后台进行,不能直接重试 connect(会得到 EALREADY)。正确做法是改用 poll + SO_ERROR 确认状态,或者干脆用非阻塞 connect。
2.16.5 最小 TCP 服务端
下面的服务端是「完整但最小」的:getaddrinfo 解析、SO_REUSEADDR、accept 循环、短读短写、EINTR、SIGPIPE 全部处理。它一次只服务一条连接(迭代型),够用来验证协议;并发版本在 2.17 与 2.18。
/* tcp_echo_server.c:最小的迭代型 TCP echo 服务端 */
#include <errno.h>
#include <netdb.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#define PORT "12345"
#define BACKLOG 16
#define BUF_SIZE 4096
static volatile sig_atomic_t stop;
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
static void on_sigint(int sig)
{
(void)sig;
stop = 1;
}
int main(void)
{
/* Ctrl-C 只置标志,不直接退出,便于关闭监听 fd */
struct sigaction sa = {0};
sa.sa_handler = on_sigint;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGINT, &sa, NULL) == -1)
die("sigaction SIGINT");
/* 写已关闭的连接会触发 SIGPIPE,忽略它让 write 返回 EPIPE */
struct sigaction ign = {0};
ign.sa_handler = SIG_IGN;
sigemptyset(&ign.sa_mask);
if (sigaction(SIGPIPE, &ign, NULL) == -1)
die("sigaction SIGPIPE");
struct addrinfo hints = {0};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE; /* NULL host → 通配地址 */
struct addrinfo *res = NULL;
int rc = getaddrinfo(NULL, PORT, &hints, &res);
if (rc != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
return EXIT_FAILURE;
}
int lfd = -1;
for (struct addrinfo *ai = res; ai != NULL; ai = ai->ai_next) {
lfd = socket(ai->ai_family, ai->ai_socktype | SOCK_CLOEXEC, ai->ai_protocol);
if (lfd == -1)
continue;
int one = 1;
if (setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one) == -1) {
close(lfd);
continue;
}
if (bind(lfd, ai->ai_addr, ai->ai_addrlen) == 0)
break; /* 绑定成功 */
close(lfd);
lfd = -1;
}
freeaddrinfo(res);
if (lfd == -1)
die("bind");
if (listen(lfd, BACKLOG) == -1)
die("listen");
printf("listening on port %s\n", PORT);
fflush(stdout);
char buf[BUF_SIZE];
while (!stop) {
struct sockaddr_storage peer;
socklen_t peer_len = sizeof peer;
int cfd = accept(lfd, (struct sockaddr *)&peer, &peer_len);
if (cfd == -1) {
if (errno == EINTR)
continue; /* 信号打断,回到循环看 stop */
perror("accept");
break;
}
char host[NI_MAXHOST], serv[NI_MAXSERV];
if (getnameinfo((struct sockaddr *)&peer, peer_len,
host, sizeof host, serv, sizeof serv,
NI_NUMERICHOST | NI_NUMERICSERV) == 0)
printf("connection from %s:%s\n", host, serv);
for (;;) {
ssize_t n = read(cfd, buf, sizeof buf);
if (n == 0)
break; /* 对端关闭写方向 */
if (n == -1) {
if (errno == EINTR)
continue;
perror("read");
break;
}
ssize_t off = 0;
while (off < n) {
ssize_t w = write(cfd, buf + off, (size_t)(n - off));
if (w == -1) {
if (errno == EINTR)
continue;
perror("write");
break;
}
off += w;
}
if (off < n)
break; /* 写失败,放弃这条连接 */
}
if (close(cfd) == -1)
perror("close cfd");
printf("connection closed\n");
}
if (close(lfd) == -1)
perror("close lfd");
printf("server stopped\n");
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g tcp_echo_server.c -o tcp_echo_server
./tcp_echo_server
另开一个终端:
printf 'hello\nworld\n' | nc 127.0.0.1 12345
hello
world
服务端输出:
listening on port 12345
connection from 127.0.0.1:52344
connection closed
关键行:
sigaction(SIGINT, ...)不设SA_RESTART,让阻塞的accept返回EINTR,循环才能检查stop;SO_REUSEADDR在bind之前设置,解决重启时的EADDRINUSE;getaddrinfo返回的列表要遍历,直到某个地址bind成功;freeaddrinfo不能漏;- 读循环里
n == 0是对端close/shutdown(SHUT_WR),不是错误; - 写循环处理短写,
off < n表示失败,直接关连接。
配套客户端
/* tcp_echo_client.c:连接服务端,发送一行,读回 echo */
#include <errno.h>
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
int main(int argc, char **argv)
{
const char *host = argc > 1 ? argv[1] : "127.0.0.1";
const char *port = argc > 2 ? argv[2] : "12345";
const char *msg = argc > 3 ? argv[3] : "hello from client\n";
struct addrinfo hints = {0};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
struct addrinfo *res = NULL;
int rc = getaddrinfo(host, port, &hints, &res);
if (rc != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
return EXIT_FAILURE;
}
int fd = -1;
for (struct addrinfo *ai = res; ai != NULL; ai = ai->ai_next) {
fd = socket(ai->ai_family, ai->ai_socktype | SOCK_CLOEXEC, ai->ai_protocol);
if (fd == -1)
continue;
if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0)
break;
close(fd);
fd = -1;
}
freeaddrinfo(res);
if (fd == -1)
die("connect");
size_t len = strlen(msg);
size_t off = 0;
while (off < len) {
ssize_t n = write(fd, msg + off, len - off);
if (n == -1) {
if (errno == EINTR)
continue;
die("write");
}
off += (size_t)n;
}
/* 告诉服务端「我写完了」,但仍可以读 */
if (shutdown(fd, SHUT_WR) == -1)
die("shutdown");
char buf[1024];
for (;;) {
ssize_t n = read(fd, buf, sizeof buf);
if (n == 0)
break;
if (n == -1) {
if (errno == EINTR)
continue;
die("read");
}
if (write(STDOUT_FILENO, buf, (size_t)n) == -1)
die("write stdout");
}
if (close(fd) == -1)
die("close");
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g tcp_echo_client.c -o tcp_echo_client
./tcp_echo_client 127.0.0.1 12345 "ping"
客户端用 shutdown(SHUT_WR) 发送 FIN,服务端读到 EOF 后关闭连接,客户端再读到 EOF 退出。如果客户端直接 close(fd),它就不能再读回 echo 了——close 会同时关闭读写两个方向。这是 2.17 优雅关闭的起点。
2.16.6 名字与地址:getaddrinfo 与 getnameinfo
gethostbyname 是 IPv4 时代的接口,不可重入、不支持 IPv6、不能指定服务类型。现代代码用 getaddrinfo:
struct addrinfo hints = {0};
hints.ai_family = AF_UNSPEC; /* IPv4 或 IPv6 都行 */
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE; /* 给 bind 用:NULL host → 通配地址 */
struct addrinfo *res;
int rc = getaddrinfo(NULL, "8080", &hints, &res);
if (rc != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
return EXIT_FAILURE;
}
for (struct addrinfo *ai = res; ai != NULL; ai = ai->ai_next) {
/* 依次尝试 ai->ai_family / ai_socktype / ai_protocol */
}
freeaddrinfo(res);
getaddrinfo 的三个返回值层次要分清:
- 返回 0:成功,
res是链表; - 返回
EAI_SYSTEM:系统错误,原因在errno; - 其他
EAI_*:解析错误,用gai_strerror(rc)转文字。
AI_ADDRCONFIG 只返回本机配置了对应地址族的记录(IPv6-only 环境不会返回 A 记录);AI_NUMERICHOST 禁止 DNS 查询;AI_CANONNAME 要求返回规范主机名。服务端用 AI_PASSIVE,客户端不要用。
反向转换用 getnameinfo:
char host[NI_MAXHOST], serv[NI_MAXSERV];
int rc = getnameinfo((struct sockaddr *)&peer, peer_len,
host, sizeof host, serv, sizeof serv,
NI_NUMERICHOST | NI_NUMERICSERV);
NI_NUMERICHOST 禁止反查 DNS——日志里打印对端地址时应该加上,否则每个连接都要等一次 DNS,慢且可能被伪造的 PTR 记录影响。
2.16.7 UDP:有边界、不保证到达
UDP 的接口只有四个函数:sendto、recvfrom、以及可选的 connect/send/recv。与 TCP 的根本差异:
| 维度 | TCP | UDP |
|---|---|---|
| 连接 | 三次握手,面向连接 | 无连接,每个数据报独立 |
| 边界 | 字节流,无边界 | 保留发送方的数据报边界 |
| 可靠性 | 确认、重传、排序 | 不保证到达、不保证顺序、可能重复 |
| 流量控制 | 滑动窗口、拥塞控制 | 无 |
read 语义 | 可能短读,返回 0 是 EOF | 一次返回一个完整数据报;缓冲区小则截断 |
| 典型用途 | HTTP、SSH、数据库 | DNS、日志、音视频、服务发现 |
/* udp_echo_server.c:UDP echo 服务端 */
#include <errno.h>
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#define PORT "12346"
#define BUF_SIZE 2048
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
int main(void)
{
struct addrinfo hints = {0};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_DGRAM;
hints.ai_flags = AI_PASSIVE;
struct addrinfo *res = NULL;
int rc = getaddrinfo(NULL, PORT, &hints, &res);
if (rc != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
return EXIT_FAILURE;
}
int fd = -1;
for (struct addrinfo *ai = res; ai != NULL; ai = ai->ai_next) {
fd = socket(ai->ai_family, ai->ai_socktype | SOCK_CLOEXEC, ai->ai_protocol);
if (fd == -1)
continue;
if (bind(fd, ai->ai_addr, ai->ai_addrlen) == 0)
break;
close(fd);
fd = -1;
}
freeaddrinfo(res);
if (fd == -1)
die("bind");
printf("udp echo server on %s\n", PORT);
fflush(stdout);
char buf[BUF_SIZE];
for (;;) {
struct sockaddr_storage peer;
socklen_t peer_len = sizeof peer;
ssize_t n = recvfrom(fd, buf, sizeof buf, 0,
(struct sockaddr *)&peer, &peer_len);
if (n == -1) {
if (errno == EINTR)
continue;
perror("recvfrom");
break;
}
printf("datagram %zd bytes\n", n);
if (sendto(fd, buf, (size_t)n, 0,
(struct sockaddr *)&peer, peer_len) != n) {
perror("sendto");
}
}
close(fd);
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g udp_echo_server.c -o udp_echo_server
./udp_echo_server
# 另一个终端:
printf 'first' | nc -u -w1 127.0.0.1 12346
first
连续发送两个数据报(比如用 nc 两次,或自己写客户端调两次 sendto),服务端会 recvfrom 两次,每次一个完整数据报;即使它们在网络上被合并成一个 IP 分片、或在同一个缓冲区里到达,UDP 层也会把边界还原。TCP 没有这个保证——这是「UDP 适合消息、TCP 适合流」的接口层原因。
recvfrom 的缓冲区不够大时,数据报被截断,多余部分丢弃,返回值是缓冲区大小(除非用 MSG_TRUNC 拿到真实长度)。TCP 没有这个问题:没读完的字节留在内核缓冲区,下次 read 继续。
2.16.8 UNIX domain socket:本机的高效通道
AF_UNIX 不经过网络协议栈,数据在本机内核里复制,没有 IP/TCP 头、没有校验和、没有拥塞控制。它比 TCP 回环快,而且可以用文件系统权限控制访问。
struct sockaddr_un addr = {0};
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, "/tmp/demo.sock", sizeof addr.sun_path - 1);
/* sun_path 只有 108 字节,strncpy 后要保证结尾有 \0 */
int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
unlink("/tmp/demo.sock"); /* 清理上次残留 */
if (bind(fd, (struct sockaddr *)&addr, sizeof addr) == -1)
perror("bind");
if (listen(fd, 16) == -1)
perror("listen");
三种地址形式:
- 路径名:绑定后在文件系统里创建一个 socket 文件,权限由
chmod/umask控制;进程退出后文件残留,需要unlink。 - 抽象命名空间(Linux):
sun_path[0] == '\0',名字是sun_path + 1开始的字节串,不受文件系统权限约束,进程退出自动消失。适合不需要权限控制的内部服务。 - 匿名:
socketpair()创建一对已连接的 socket,不绑定名字。
socketpair 是最常用的本机双向通道:
int sv[2];
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sv) == -1) {
perror("socketpair");
exit(EXIT_FAILURE);
}
/* sv[0] 与 sv[1] 双向可读写;fork 后父子各持一端 */
它比「两条 pipe」简洁,且支持 SOCK_SEQPACKET(保留消息边界)。SCM_RIGHTS(传递 fd)也依赖 UNIX domain socket,见 2.17。
- 「UNIX socket 文件可以用
open打开」——不能。它是 socket 类型的特殊文件,只能用socket+connect访问。 - 「抽象命名空间也要
unlink」——不需要,进程退出自动释放;路径名 socket 才需要。 - 「UNIX socket 比 TCP 慢」——本机通信通常更快:没有协议栈封装、没有校验和、没有握手(
socketpair直接连好)。但它是本机的,不能跨主机。
2.16.9 close、shutdown 与 TIME_WAIT
close(fd) 减少 fd 的引用计数;最后一个引用关闭时,内核向对端发 FIN。shutdown(fd, how) 只关闭一个方向:
| 调用 | 效果 |
|---|---|
shutdown(fd, SHUT_WR) | 发送 FIN,本端不能再写,但仍可读 |
shutdown(fd, SHUT_RD) | 本端 read 返回 0;到达数据被 ACK 后丢弃(不主动发 RST) |
shutdown(fd, SHUT_RDWR) | 两个方向都关闭,类似 close 但不释放 fd |
close(fd) | 释放 fd;最后一个引用时关闭连接 |
shutdown(SHUT_WR) 是「优雅关闭」的关键:它让对端读到 EOF,但自己还能读完对端剩下的数据。HTTP/1.1 的 Connection: close、gRPC 的 GOAWAY 都依赖半关闭。
TIME_WAIT 出现在主动关闭的一方:发送最后一个 ACK 后,连接在 TIME_WAIT 停留 2MSL(Linux 固定 60 秒),目的是让重传的 FIN 能被正确响应,并让旧连接的重复报文在网络中消失。
这带来两个现象:
- 服务端主动关闭大量短连接时,
ss -tan里出现大量TIME_WAIT,占用本地端口; - 服务端重启时
bind返回EADDRINUSE,因为旧连接的TIME_WAIT还占着四元组。
SO_REUSEADDR 解决第二个问题;第一个问题要靠「让客户端先关」或连接复用(HTTP keep-alive)。调 net.ipv4.tcp_tw_reuse 要谨慎:它只对主动发起连接的 socket 生效,且依赖时间戳,跨 NAT 场景可能出问题。
2.16.10 socket():三个参数与一张 errno 表
这一节回答:socket() 的 domain、type、protocol 各自在内核里决定什么,哪些错误在「创建」阶段就会返回,而不是等到 bind/connect。
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
type 的低 4 位是「套接字类型」,高位是「创建标志」。Linux 的 UAPI 把这两类值设计成可以直接按位或:
| 值 | 数值(Linux UAPI) | 含义 | 备注 |
|---|---|---|---|
SOCK_STREAM | 1 | 有序、可靠、双向字节流 | TCP、UNIX stream |
SOCK_DGRAM | 2 | 保留边界的数据报 | UDP、UNIX datagram |
SOCK_RAW | 3 | 原始套接字,绕过传输层 | 需要 CAP_NET_RAW |
SOCK_RDM | 4 | 可靠数据报 | Linux 基本未实现,常返回 ESOCKTNOSUPPORT |
SOCK_SEQPACKET | 5 | 有序、可靠、保留边界 | AF_UNIX 支持;AF_INET 不支持 |
SOCK_NONBLOCK | O_NONBLOCK | 新 fd 置非阻塞 | 与 fcntl 设置等价 |
SOCK_CLOEXEC | O_CLOEXEC | 新 fd 置 FD_CLOEXEC | 避免 fork+exec 泄漏 |
protocol 通常填 0,让内核按 (domain, type) 选默认协议;也可以显式指定:
domain | type | protocol | 实际协议 |
|---|---|---|---|
AF_INET/AF_INET6 | SOCK_STREAM | 0 或 IPPROTO_TCP | TCP |
AF_INET/AF_INET6 | SOCK_DGRAM | 0 或 IPPROTO_UDP | UDP |
AF_INET/AF_INET6 | SOCK_STREAM | IPPROTO_MPTCP(262) | MPTCP(Linux 5.6+) |
AF_INET | SOCK_RAW | IPPROTO_ICMP/IPPROTO_RAW | 原始 IP/ICMP |
AF_UNIX | 任意已支持类型 | 0 | UNIX domain |
AF_NETLINK | SOCK_RAW/SOCK_DGRAM | NETLINK_ROUTE 等 | 内核-用户态消息 |
一个常见误解是「SOCK_STREAM 一定是 TCP」。AF_UNIX + SOCK_STREAM 是本机字节流,没有 IP 头、没有握手、没有拥塞控制;AF_VSOCK + SOCK_STREAM 又是另一套实现。类型只描述语义,协议由 domain 决定。
创建阶段的 errno
| errno | 触发条件 | 排查 |
|---|---|---|
EAFNOSUPPORT | domain 未注册(如未加载 af_key) | 检查 family 拼写、内核模块 |
EPROTONOSUPPORT | 该 family 不支持此 protocol | 如 AF_INET + SOCK_STREAM + IPPROTO_UDP |
ESOCKTNOSUPPORT | 该 family 不支持此 type | 如 AF_INET + SOCK_SEQPACKET |
EINVAL | type 含未知位,或 type/protocol 组合非法 | 检查是否把自定义标志塞进 type |
EACCES/EPERM | 创建 SOCK_RAW 缺少 CAP_NET_RAW,或被 seccomp/LSM 拒绝 | capsh --print、容器 --cap-add=NET_RAW |
EMFILE | 进程 fd 达到 RLIMIT_NOFILE | ls /proc/{pid}/fd 计数 |
ENFILE | 系统级文件表满 | file-nr、fs.file-max |
ENOBUFS/ENOMEM | 内核内存不足 | 查 dmesg、cgroup 内存限制 |
SOCK_CLOEXEC 的价值不在「少一次 fcntl」,而在原子性。下面这段代码在多线程程序里有竞态:
int fd = socket(AF_INET, SOCK_STREAM, 0); /* 此时 fd 没有 CLOEXEC */
/* 另一个线程 fork+exec:子进程继承了 fd */
fcntl(fd, F_SETFD, FD_CLOEXEC); /* 已经晚了 */
用 socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0) 把「创建」与「设置」合成一个原子操作,exec 之前不存在泄漏窗口。accept4、pipe2、dup3、eventfd 的 EFD_CLOEXEC 都是同一动机。
内核路径:从 socket() 到 struct sock
/* net/socket.c,Linux 6.8(节选,保留错误处理结构) */
int __sys_socket(int family, int type, int protocol)
{
int flags = type & ~SOCK_TYPE_MASK;
struct socket *sock;
if (SOCK_NONBLOCK != O_NONBLOCK && (flags & SOCK_NONBLOCK))
flags = (flags & ~SOCK_NONBLOCK) | O_NONBLOCK;
int retval = sock_create(family, type, protocol, &sock);
if (retval < 0)
return retval;
return sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));
}
调用链是:
socket(2)
└─ __sys_socket() net/socket.c
├─ sock_create() → __sock_create()
│ ├─ net_families[family] → struct net_proto_family
│ └─ pf->create() → inet_create() / unix_create() / ...
│ ├─ sk_alloc() 分配 struct sock(slab)
│ └─ sock_init_data() 绑定 socket 与 sock
└─ sock_map_fd() → sock_alloc_file() → fd_install()
struct socket 是「面向 VFS 的壳」,struct sock 是「协议栈的对象」。同一个连接在两套结构里各有一份状态,字段分工如下:
| 结构 | 关键字段 | 谁维护 | 何时变 |
|---|---|---|---|
struct socket | state(SS_UNCONNECTED/SS_CONNECTING/SS_CONNECTED) | 协议层 | connect/accept/shutdown |
type、flags | 创建时 | 不变 | |
ops(struct proto_ops) | family | 创建时按 family 赋值 | |
sk | 创建时 | 指向协议对象 | |
file | sock_alloc_file | 创建时 | |
struct sock | sk_state(TCP_ESTABLISHED/TCP_CLOSE/…) | TCP 状态机 | 收发包、超时、关闭 |
sk_receive_queue | 软中断/协议层 | 数据到达、应用读取 | |
sk_write_queue | 发送路径 | 应用写入、ACK 释放 | |
sk_error_queue | ICMP/错误路径 | MSG_ERRQUEUE 读取 | |
sk_rcvbuf/sk_sndbuf | setsockopt/自动调优 | SO_RCVBUF/SO_SNDBUF | |
sk_rcvtimeo/sk_sndtimeo | SO_RCVTIMEO/SO_SNDTIMEO | 每次 setsockopt | |
sk_wq | 等待队列 | poll/epoll 注册的回调挂在这里 |
sk_receive_queue 里挂的是 sk_buff(skb),每个 skb 指向一段内核缓冲区。应用 read 时,tcp_recvmsg_locked 从队列里摘 skb、skb_copy_datagram_msg 拷到用户缓冲区,然后 tcp_cleanup_rbuf 更新窗口并可能发 ACK。「socket 是 fd」只说明它有一个 struct file,数据既不在 struct file 里,也不在页缓存里,而在协议栈的 skb 队列里——这是 socket 与普通文件最根本的结构差异。
2.16.11 bind() 与 listen():地址冲突矩阵与两个队列
这一节回答:bind 到底在检查什么,为什么 SO_REUSEADDR 有时救不了 EADDRINUSE,listen 的 backlog 最终由谁截断。
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
int listen(int sockfd, int backlog);
bind 没有 flags。它的全部语义由「地址内容 + 地址长度 + socket 选项 + 当前内核里的绑定表」决定。
地址长度必须按 family 给
| 地址 | 结构大小 | addrlen 该传 | 传错的后果 |
|---|---|---|---|
| IPv4 | sizeof(struct sockaddr_in) = 16 | 16 | 小于 16 常返回 EINVAL |
| IPv6 | sizeof(struct sockaddr_in6) = 28 | 28 | 少传会丢 scope_id |
| UNIX 路径名 | offsetof(struct sockaddr_un, sun_path) + strlen(path) + 1 | 实际长度 | 多传的尾部字节被当成名字的一部分 |
| UNIX 抽象名 | offsetof(...) + 1 + namelen | 实际长度 | 名字里可以含 \0,不能 strlen |
| 通用容器 | sizeof(struct sockaddr_storage) = 128 | 由具体 family 决定 | 仅用于接收,不用于 bind |
bind 的 addrlen 不是「缓冲区大小」,而是「地址有多少字节有效」。IPv4 的 sockaddr_in 里有 8 字节 sin_zero 填充,习惯上 memset 清零,但内核并不依赖它。
端口 0 与临时端口
sin_port = 0 表示「让内核选一个」。内核在 inet_csk_get_port 里从 net.ipv4.ip_local_port_range(默认 32768 60999)选端口,避开已有绑定,并尽量让同一四元组的端口可复用。绑定后用 getsockname 取回:
struct sockaddr_in addr = { .sin_family = AF_INET,
.sin_port = 0,
.sin_addr.s_addr = htonl(INADDR_ANY) };
if (bind(fd, (struct sockaddr *)&addr, sizeof addr) == -1)
perror("bind");
socklen_t len = sizeof addr;
if (getsockname(fd, (struct sockaddr *)&addr, &len) == -1)
perror("getsockname");
printf("ephemeral port = %d\n", (int)ntohs(addr.sin_port));
临时端口范围可以通过 sysctl net.ipv4.ip_local_port_range="10240 65000" 调整,但扩大范围会压缩「客户端连接可用的源端口」,在百万连接场景要一起算(见母本 N 第 8 章的端口账)。
特权端口
端口小于 1024 需要 CAP_NET_BIND_SERVICE(root 默认有)。Linux 5.7+ 提供 net.ipv4.ip_unprivileged_port_start,把它设为 0 就允许非特权进程绑定任意端口;容器里常见 --sysctl net.ipv4.ip_unprivileged_port_start=0。
EADDRINUSE 的四种来源
| 现场 | ss 看到什么 | 原因 | 处理 |
|---|---|---|---|
已有进程在 LISTEN 同一地址 | ss -lntp 有进程 | 真正的端口占用 | 换端口或停旧进程 |
旧连接在 TIME_WAIT | ss -tan state time-wait | 四元组仍被占 | SO_REUSEADDR 或等 60 秒 |
| 通配地址与具体地址冲突 | 一个 0.0.0.0:80,一个 127.0.0.1:80 | 绑定表允许部分重叠,但要看选项 | 两边都设 SO_REUSEADDR,或只绑一个 |
| IPv6 双栈占用 IPv4 | ss -lnt 看到 *:80(v6) | IPV6_V6ONLY=0 时 :: 同时占 IPv4 | 设 IPV6_V6ONLY=1 或改用同一 family |
SO_REUSEADDR 的确切语义
SO_REUSEADDR 在 Linux 上做三件事(TCP):
- 允许
bind一个处于TIME_WAIT的本地地址; - 允许在同一端口上绑定不同的本地地址(一个通配、一个具体),前提是冲突的 socket 都设了
SO_REUSEADDR; - 对 UDP 允许部分多播/广播场景的重复绑定。
它不允许两个都处于 LISTEN 的 socket 绑定完全相同的 (地址, 端口);那仍然返回 EADDRINUSE。很多「开了 SO_REUSEADDR 还是起不来」的现场,是因为旧进程还活着,而不是 TIME_WAIT。
int one = 1;
if (setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one) == -1)
perror("setsockopt SO_REUSEADDR");
/* 必须在 bind 之前设置;bind 之后再设对本次绑定无效 */
SO_REUSEPORT:真正的多进程同端口
SO_REUSEPORT(Linux 3.9+)允许多个处于 LISTEN 的 socket绑定完全相同的地址与端口,内核在 bind 时把它们放进同一个 reuseport 组,收到 SYN 时按四元组哈希选一个 socket。使用条件:
- 所有 socket 都设置
SO_REUSEPORT,family/type/protocol 相同,绑定完全相同的地址(不能一个0.0.0.0、一个127.0.0.1),且有效 UID 相同;主要支持 TCP/UDP,UNIX domain 不支持。
int one = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one);
内核用 reuseport_select_sock 选 socket,默认按四元组哈希;可以用 SO_ATTACH_REUSEPORT_CBPF/SO_ATTACH_REUSEPORT_EBPF 自定义选择逻辑,用 SO_INCOMING_CPU 做 CPU 亲和。Nginx 的 reuseport、Envoy 的 SO_REUSEPORT 多 worker 都依赖它。
| 选项 | 两个 LISTEN 同地址 | 绑定 TIME_WAIT | 分流 | 典型用途 |
|---|---|---|---|---|
| 无 | EADDRINUSE | EADDRINUSE | — | — |
SO_REUSEADDR | EADDRINUSE | 允许 | — | 服务重启 |
SO_REUSEPORT | 允许 | 允许 | 四元组哈希 | 多 worker 监听 |
listen() 与 backlog 的真实上限
if (listen(lfd, 128) == -1)
perror("listen");
backlog 不是「总连接数」,而是 accept 队列(全连接队列)的最大长度。内核在 __sys_listen 里先做一次截断:
/* net/socket.c,Linux 6.8(节选) */
int __sys_listen(int fd, int backlog)
{
struct socket *sock;
int somaxconn;
sock = sockfd_lookup_light(fd, &err, &fput_needed);
if (sock) {
somaxconn = sock_net(sock->sk)->core.sysctl_somaxconn;
if ((unsigned int)backlog > somaxconn)
backlog = somaxconn;
err = security_socket_listen(sock, backlog);
if (!err)
err = sock->ops->listen(sock, backlog);
...
}
}
所以实际上限是 min(backlog, net.core.somaxconn)。somaxconn 在 Linux 5.4 之前的默认值是 128,之后是 4096;systemd 和一些发行版会把它调到 4096 或更高。listen(fd, -1) 会被转成无符号大数,再被截到 somaxconn——不是 0,也不是「无限制」。
ss -lnt 的两列正好对应两个队列:
State Recv-Q Send-Q Local Address:Port
LISTEN 0 128 0.0.0.0:12345
Recv-Q:当前 accept 队列里已完成握手、等待accept的连接数;Send-Q:sk_max_ack_backlog,即实际生效的 backlog 上限。
当 Recv-Q 接近 Send-Q 时,新连接开始被丢弃。证据在 /proc/net/netstat:
nstat -az TcpExtListenOverflows TcpExtListenDrops
# TcpExtListenOverflows 12 0.0
# TcpExtListenDrops 12 0.0
ListenOverflows 表示 accept 队列满导致 ACK 被丢弃的次数,ListenDrops 是所有 listen 阶段丢包的总和。两者持续增长,说明应用 accept 太慢或 backlog 太小。
SYN 队列与 syncookie
三次握手的第一个阶段,连接处于 SYN 队列(半连接队列),由 net.ipv4.tcp_max_syn_backlog 限制。SYN 队列满时:
net.ipv4.tcp_syncookies=1(默认)时,内核可以不算半连接,直接把状态编码进 SYN-ACK 的序列号(syncookie),客户端 ACK 回来再重建请求;tcp_syncookies=0时,新的 SYN 被丢弃,客户端重传。
syncookie 的代价是:SYN-ACK 里不能携带某些 TCP 选项(如窗口缩放、SACK 的部分信息),握手完成后的性能可能受影响。观测:
nstat -az TcpExtSyncookiesSent TcpExtSyncookiesRecv TcpExtSyncookiesFailed
net.ipv4.tcp_abort_on_overflow 决定 accept 队列满时的行为:默认 0,丢弃 ACK 让客户端重传(表现为连接慢);设为 1 则直接回 RST(客户端看到 ECONNRESET)。生产环境一般保持 0,先把 accept 循环和 backlog 修好。
2.16.12 accept4() 与 connect():全 flags、全 errno
这一节回答:accept 返回的新 fd 继承什么、不继承什么,connect 在阻塞/非阻塞/被信号打断三种情况下分别把连接留在什么状态。
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
int accept4(int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags);
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
accept4 的 flags
accept4 只接受两个标志,其他位返回 EINVAL:
| flag | 作用 | 不用的后果 |
|---|---|---|
SOCK_CLOEXEC | 新 fd 原子设置 FD_CLOEXEC | 多线程 fork+exec 时泄漏连接 |
SOCK_NONBLOCK | 新 fd 原子设置 O_NONBLOCK | 需要额外 fcntl,有竞态 |
accept 本身没有 flags 参数;accept4 是 Linux 2.6.28+ 的扩展,glibc 在 _GNU_SOURCE 下声明。可移植代码可以 accept + fcntl,但要接受竞态窗口。
新 fd 继承什么
| 属性 | 是否继承 | 说明 |
|---|---|---|
O_NONBLOCK | 不继承 | Linux 上新 fd 默认阻塞;监听 fd 非阻塞只影响 accept 本身 |
O_ASYNC | 不继承 | 需要重新 fcntl(F_SETFL) |
FD_CLOEXEC | 不继承 | accept 返回的 fd 默认没有 CLOEXEC |
SO_RCVBUF/SO_SNDBUF | Linux 通常继承 | sk_clone_lock 复制缓冲区大小,受 rmem_max/wmem_max 限制 |
TCP_NODELAY | Linux 上继承 | 但不要依赖,显式设置更稳 |
SO_LINGER | 不保证 | 各协议实现不同 |
SO_REUSEADDR/SO_REUSEPORT | 无意义 | 只影响 bind,新连接已绑定 |
O_NONBLOCK 不继承是最容易踩的一条:监听 fd 设了非阻塞,accept 在没连接时返回 EAGAIN,但 accept 返回的 cfd 仍然是阻塞的;在事件循环里直接 read(cfd) 会卡住整个线程。正确写法是 accept4(..., SOCK_NONBLOCK | SOCK_CLOEXEC)。
accept 的 errno
| errno | 含义 | 处理 |
|---|---|---|
EAGAIN/EWOULDBLOCK | 非阻塞监听 fd,当前无已完成连接 | 回去等 poll/epoll |
EINTR | 被信号打断 | 重试;不要当成「没有连接」 |
ECONNABORTED | 连接在 accept 前被对端/RST 中止 | 记录并继续 |
EMFILE | 进程 fd 耗尽 | 预留 fd 技巧(见下) |
ENFILE | 系统文件表耗尽 | 降载、查泄漏 |
ENOBUFS/ENOMEM | 内存不足 | 降载 |
EINVAL | socket 不是监听态,或 flags 非法 | 检查 listen 是否成功 |
EBADF/ENOTSOCK | fd 无效/不是 socket | 检查生命周期 |
EOPNOTSUPP | 该协议不支持 accept | 如 UDP |
EMFILE 的经典处理是「预留一个 fd」:进程启动时 open("/dev/null") 留一个空位;accept 返回 EMFILE 时,close 预留 fd、accept 一条连接、立刻 close,再重新打开预留 fd。这样至少能把连接从队列里取出来并回一个错误,而不是让队列一直满着:
static int reserve_fd = -1;
void init_reserve(void) { reserve_fd = open("/dev/null", O_RDONLY | O_CLOEXEC); }
int accept_or_recover(int lfd)
{
int cfd = accept4(lfd, NULL, NULL, SOCK_CLOEXEC | SOCK_NONBLOCK);
if (cfd != -1 || errno != EMFILE)
return cfd;
close(reserve_fd); /* 腾出一个 fd 名额 */
cfd = accept(lfd, NULL, NULL); /* 取出连接,避免队列继续满 */
if (cfd != -1)
close(cfd); /* 关闭,客户端得到错误 */
reserve_fd = open("/dev/null", O_RDONLY | O_CLOEXEC);
errno = EMFILE;
return -1;
}
connect 的状态机
connect 没有 flags。它的行为由 socket 的 O_NONBLOCK 和当前状态决定:
| 场景 | 返回值 | errno | 连接状态 |
|---|---|---|---|
| 阻塞,握手成功 | 0 | — | ESTABLISHED |
| 阻塞,被拒绝 | -1 | ECONNREFUSED | CLOSED |
| 阻塞,超时 | -1 | ETIMEDOUT | CLOSED |
| 阻塞,被信号打断 | -1 | EINTR | 可能仍在 SYN_SENT |
| 非阻塞,立即成功(本机/已缓存路由) | 0 | — | ESTABLISHED |
| 非阻塞,进行中 | -1 | EINPROGRESS | SYN_SENT |
| 非阻塞,立即失败 | -1 | ECONNREFUSED 等 | CLOSED |
已连接再 connect | -1 | EISCONN | 不变 |
非阻塞连接未完成再 connect | -1 | EALREADY | 不变 |
connect 的完整 errno:
| errno | 触发 |
|---|---|
EAFNOSUPPORT | 地址 family 与 socket 不符 |
EADDRNOTAVAIL | 源地址不可用(绑定了一个不属于本机的 IP) |
ECONNREFUSED | 端口无监听,收到 RST |
ETIMEDOUT | SYN 重传耗尽(tcp_syn_retries) |
ENETUNREACH/EHOSTUNREACH | 无路由/主机不可达(可能来自 ICMP) |
EACCES | 广播地址未设 SO_BROADCAST,或被防火墙拒绝 |
EPERM | 防火墙 REJECT 或 LSM |
EINPROGRESS | 非阻塞连接已启动 |
EALREADY | 上一次非阻塞连接还没完成 |
EISCONN | 已连接 |
EINTR | 阻塞 connect 被信号打断 |
EINTR 的处理是本章最容易写错的地方。阻塞 connect 被信号打断时,内核并没有取消三次握手;连接可能仍在后台进行。此时直接重试 connect 会得到 EALREADY,正确做法是:
int rc = connect(fd, addr, addrlen);
if (rc == -1 && errno == EINTR) {
/* 不要直接 goto retry_connect */
struct pollfd pfd = { .fd = fd, .events = POLLOUT };
int pr = poll(&pfd, 1, 5000);
if (pr == 0) { /* 超时 */ }
else if (pr > 0) {
int err = 0;
socklen_t len = sizeof err;
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len) == -1 || err != 0) {
/* 连接失败,err 是原因 */
}
}
}
poll 返回 POLLOUT 只表示「有事件」,连接可能成功也可能失败;必须用 SO_ERROR 取结果。getsockopt(SO_ERROR) 会读取并清除 sk_err,所以只查一次,别在多个线程里重复查。
内核路径:connect 如何发出第一个 SYN
inet_stream_connect(net/ipv4/af_inet.c)把 sock->state 从 SS_UNCONNECTED 推到 SS_CONNECTING,调用 sk->sk_prot->connect(TCP 是 tcp_v4_connect),返回 EINPROGRESS。
tcp_v4_connect 选源地址、查路由、填 struct sock 的四元组,然后 tcp_connect 构造 SYN、tcp_transmit_skb 交给 IP 层。SYN 发出后 socket 进入 TCP_SYN_SENT;收到 SYN+ACK 后,软中断里的 tcp_rcv_state_process 把它推进到 TCP_ESTABLISHED 并唤醒等待者。非阻塞 connect 返回 EINPROGRESS 时,握手已经在进行,不是「还没开始」。
2.16.13 sockaddr 家族全表:不止 IPv4 与 IPv6
这一节回答:sockaddr 到底有多少种,sockaddr_storage 为什么是 128 字节,IPv6 的 scope_id 与 flowinfo 在什么场景下非填不可。
socket() 的 domain 与 sockaddr 的 sa_family 是同一套编号。每个 family 注册一个 struct net_proto_family,其中 create 指向该 family 的套接字构造函数:
| family | 值 | sockaddr 具体结构 | 典型 create | 用途 |
|---|---|---|---|---|
AF_UNIX | 1 | sockaddr_un | unix_create | 本机进程通信 |
AF_INET | 2 | sockaddr_in | inet_create | IPv4 |
AF_INET6 | 10 | sockaddr_in6 | inet6_create | IPv6 |
AF_NETLINK | 16 | sockaddr_nl | netlink_create | 路由、审计、uevent |
AF_PACKET | 17 | sockaddr_ll | packet_create | 链路层抓包/注入 |
AF_ALG | 38 | sockaddr_alg | alg_create | 内核 crypto 接口 |
AF_VSOCK | 40 | sockaddr_vm | vsock_create | 虚拟机与宿主通信 |
AF_TIPC | 30 | sockaddr_tipc | tipc_create | 集群内寻址 |
AF_CAN | 29 | sockaddr_can | can_create | CAN 总线 |
AF_BLUETOOTH | 31 | sockaddr_hci 等 | bt_sock_create | 蓝牙 |
AF_XDP | 44 | sockaddr_xdp | xsk_create | 高性能收包 |
AF_MPTCP 没有独立的地址族;MPTCP 复用 AF_INET/AF_INET6,靠 socket() 的 protocol = IPPROTO_MPTCP 选择。类似地,AF_INET6 在 IPV6_V6ONLY=0 时可以接收 IPv4 连接,地址用 IPv4-mapped 形式 ::ffff:127.0.0.1 表示。
通用头与容器
所有具体地址结构都以 sa_family_t 开头:
struct sockaddr 本身只有 16 字节,装不下 IPv6(28 字节)或 UNIX 路径(110 字节)。所以系统调用接受的是 struct sockaddr * 加一个独立的长度参数,内核按 sa_family 解释后面的字节。接收地址时用 struct sockaddr_storage:
struct sockaddr_storage {
sa_family_t ss_family; /* 偏移 0,与 sockaddr 对齐 */
char __ss_padding[118];
unsigned long __ss_align; /* 强制 8/16 字节对齐 */
};
它的大小是 128 字节,足够容纳所有 family,并且对齐满足 sockaddr_in6/sockaddr_un 的要求。把它强转成 struct sockaddr * 传给 accept/recvfrom,再按 ss_family 转回具体结构,是唯一可移植的写法。
IPv4:sockaddr_in
struct sockaddr_in {
sa_family_t sin_family; /* AF_INET */
in_port_t sin_port; /* 网络字节序 */
struct in_addr sin_addr; /* 网络字节序 */
unsigned char sin_zero[8]; /* 填充,习惯清零 */
};
sin_addr.s_addr 常用 INADDR_ANY(0.0.0.0,所有网卡)、INADDR_LOOPBACK(127.0.0.1)、INADDR_BROADCAST 或某个具体网卡地址;广播需要 SO_BROADCAST。
IPv6:sockaddr_in6 的三个额外字段
struct sockaddr_in6 {
sa_family_t sin6_family; /* AF_INET6 */
in_port_t sin6_port; /* 网络字节序 */
uint32_t sin6_flowinfo; /* 流标签,通常 0 */
struct in6_addr sin6_addr; /* 16 字节 */
uint32_t sin6_scope_id; /* 链路本地地址的接口索引 */
};
sin6_flowinfo:IPv6 流标签(traffic class + flow label),普通应用填 0;内核在IPV6_FLOWINFO_SEND等选项下才使用。sin6_scope_id:链路本地地址(fe80::/10)必须指定接口,否则connect/bind返回EINVAL或ENETUNREACH。值可以用if_nametoindex("eth0")取。- IPv4-mapped 地址(
::ffff:192.0.2.1)在双栈 socket 上代表 IPv4 对端;getnameinfo会把它格式化成 IPv4 文本。
struct sockaddr_in6 a6 = {0};
a6.sin6_family = AF_INET6;
a6.sin6_port = htons(8080);
if (inet_pton(AF_INET6, "fe80::1", &a6.sin6_addr) != 1)
perror("inet_pton");
a6.sin6_scope_id = if_nametoindex("eth0"); /* 链路本地必须 */
UNIX domain:变长地址
struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[108];
};
sun_path 是变长的:
- 路径名:以
\0结尾,addrlen = offsetof(struct sockaddr_un, sun_path) + strlen(path) + 1; - 抽象名:
sun_path[0] == '\0',名字从sun_path + 1开始,长度由addrlen决定,可以包含\0; - 匿名:
socketpair()创建,不绑定名字。
bind 的 addrlen 如果只等于 sizeof(sa_family_t),Linux 会做自动抽象绑定(autobind):内核生成一个唯一名字,之后可以用 getsockname 取回。这个特性在需要「临时本机地址」时有用,但不可移植。
这些 family 的共同点是:地址结构不止 (host, port) 两元组,所以 getaddrinfo 只覆盖 INET/INET6;其他 family 要自己填结构。getaddrinfo 的 AI_NUMERICHOST 也只对 INET/INET6 有意义。
getsockname/getpeername 的长度是「值-结果」
struct sockaddr_storage ss;
socklen_t len = sizeof ss;
if (getsockname(fd, (struct sockaddr *)&ss, &len) == -1)
perror("getsockname");
/* 返回后 len 是实际写入的字节数,可能小于 sizeof ss */
传入时 len 是缓冲区容量,返回时是实际地址长度。如果缓冲区太小,内核会截断并返回实际长度;调用方应检查 len > sizeof ss 并用 ss_family 判断类型。getpeername 在连接被对端关闭后仍可能返回旧地址;判断连接状态要用 SO_ERROR 或 read 的返回值,而不是 getpeername。
2.16.14 getaddrinfo()/getnameinfo():全 flags 与解析路径
这一节回答:getaddrinfo 的每个 ai_flags 到底改变哪一步,为什么同一个域名在不同机器上解析出不同顺序的地址,EAI_* 与 errno 该看哪个。
#include <netdb.h>
int getaddrinfo(const char *node, const char *service,
const struct addrinfo *hints, struct addrinfo **res);
void freeaddrinfo(struct addrinfo *res);
const char *gai_strerror(int errcode);
int getnameinfo(const struct sockaddr *addr, socklen_t addrlen,
char *host, socklen_t hostlen,
char *serv, socklen_t servlen, int flags);
struct addrinfo 的字段:
| 字段 | 谁填 | 含义 |
|---|---|---|
ai_flags | 调用方(hints) | 行为标志,见下表 |
ai_family | 调用方 | AF_UNSPEC/AF_INET/AF_INET6 |
ai_socktype | 调用方 | 0 表示不限,返回可能同时含 stream/dgram |
ai_protocol | 调用方 | 0 表示由 socktype 决定 |
ai_addrlen | 返回 | 地址长度 |
ai_addr | 返回 | 指向具体 sockaddr |
ai_canonname | 返回 | 仅第一个节点,且需要 AI_CANONNAME |
ai_next | 返回 | 链表下一项 |
ai_flags 全表
| flag | 值(glibc) | 作用 | 常见误用 |
|---|---|---|---|
AI_PASSIVE | 0x0001 | node == NULL 时返回通配地址(0.0.0.0/::) | 客户端误用,连到 0.0.0.0 |
AI_CANONNAME | 0x0002 | 返回规范主机名到 ai_canonname | 以为每个节点都有 |
AI_NUMERICHOST | 0x0004 | 禁止 DNS,node 必须是数字地址 | 传域名得到 EAI_NONAME |
AI_V4MAPPED | 0x0008 | 仅 AF_INET6:无 AAAA 时返回 IPv4-mapped | 与 AI_ALL 混用前没设 family |
AI_ALL | 0x0010 | 与 AI_V4MAPPED 同用:返回 AAAA + A | 单独用无效 |
AI_ADDRCONFIG | 0x0020 | 只返回本机已配置的地址族 | 只有 loopback 的容器里查不到 |
AI_NUMERICSERV | 0x0400 | service 必须是数字端口 | 传 "http" 得到 EAI_NONAME |
AI_ADDRCONFIG 的坑值得单独说:它检查的是「本机是否有该 family 的非 loopback 地址」。一个只有 lo 的容器里,即使内核支持 IPv6,getaddrinfo("example.com", ..., AF_UNSPEC|AI_ADDRCONFIG) 也可能只返回 A 记录;反过来,一个配了全局 IPv6 但没有 IPv4 默认路由的机器,可能只返回 AAAA。调试时先把 AI_ADDRCONFIG 去掉,确认是不是它导致的差异。
返回值:0 与 EAI_*
getaddrinfo 成功返回 0,失败返回非 0 的 EAI_*(不是 errno,也不会设置 errno,除非返回 EAI_SYSTEM)。
| 返回值 | 含义 | 排查 |
|---|---|---|
EAI_AGAIN | 临时失败,DNS 服务器超时/拒绝 | 重试;查 /etc/resolv.conf |
EAI_BADFLAGS | ai_flags 含非法位 | 检查是否把自定义值塞进去 |
EAI_FAIL | 不可恢复的解析失败 | DNS 返回 SERVFAIL |
EAI_FAMILY | ai_family 不支持 | 拼写错误 |
EAI_MEMORY | 内存不足 | 降载 |
EAI_NONAME | 名字或服务不存在;或 AI_NUMERICHOST/AI_NUMERICSERV 下不是数字 | 检查输入 |
EAI_SERVICE | 服务名不支持该 socktype | 如 "http" + SOCK_DGRAM |
EAI_SOCKTYPE | ai_socktype 不支持 | 拼写错误 |
EAI_SYSTEM | 系统错误,原因在 errno | 看 errno(如 ENOENT) |
EAI_OVERFLOW | 输出缓冲区不足 | 只在 getnameinfo |
EAI_NODATA | 有名字无地址(已废弃,glibc 可能不返回) | 用 EAI_NONAME 处理 |
EAI_ADDRFAMILY | 地址族不匹配(已废弃) | 用 EAI_FAMILY 处理 |
错误处理的标准写法:
int rc = getaddrinfo(host, serv, &hints, &res);
if (rc != 0) {
if (rc == EAI_SYSTEM)
fprintf(stderr, "getaddrinfo: %s\n", strerror(errno));
else
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
return -1;
}
freeaddrinfo(res);
解析路径:glibc、NSS 与 DNS
getaddrinfo 不是一个简单的 DNS 客户端,它经过 NSS(Name Service Switch):
getaddrinfo()
└─ glibc: gaih_inet()
├─ 读 /etc/nsswitch.conf 的 hosts: 行
│ hosts: files mdns4_minimal [NOTFOUND=return] dns myhostname
├─ files → /etc/hosts
├─ dns → libnss_dns → /etc/resolv.conf → DNS 服务器
├─ myhostname → systemd 提供的本机名
└─ 其他 → nss-mdns、sssd、winbind、nscd 缓存
/etc/hosts 优先于 DNS,这是本地覆盖域名最常用的手段。nscd(name service cache daemon)或 systemd-resolved 会在中间加一层缓存;systemd-resolved 把 /etc/resolv.conf 指向 127.0.0.53,实际查询走本地 socket。容器里如果 /etc/resolv.conf 指向 127.0.0.11,那是 Docker 的内嵌 DNS。
DNS 查询的细节会影响行为:
- A 与 AAAA 并行,
search/ndots决定短名先拼后缀还是先当 FQDN,options timeout:5 attempts:2决定EAI_AGAIN的出现时机。 - TCP 回退:响应被截断(TC 位)时,解析器改用 TCP 53 重查;防火墙只放 UDP 53 时,大响应会失败。
/etc/gai.conf:控制 RFC 6724 的地址排序(precedence/label),影响「IPv6 优先还是 IPv4 优先」。默认规则通常优先全局 IPv6,再 IPv4,最后 IPv4-mapped。HOSTALIASES:用户级别名文件,只对非 setuid 程序生效。
getaddrinfo 的返回顺序不是稳定契约。应用应该遍历链表逐个尝试 connect,而不是假设第一个一定最快。Happy Eyeballs(RFC 8305)就是在这个链表上做「IPv6 先试、300ms 后 IPv4 并行」的算法。
getnameinfo 的 flags
| flag | 作用 | 注意 |
|---|---|---|
NI_NUMERICHOST | 不做反向 DNS,直接输出数字地址 | 日志里必加,避免每个连接等 DNS |
NI_NUMERICSERV | 输出数字端口,不查 /etc/services | 日志里必加 |
NI_NOFQDN | 本地主机只输出短名 | 只对本地地址有意义 |
NI_NAMEREQD | 反查不到名字就返回 EAI_NONAME | 需要「必须有 PTR」时用 |
NI_DGRAM | 按 UDP 查服务名 | SOCK_DGRAM 场景 |
NI_NUMERICSCOPE | IPv6 scope 用数字而非接口名 | 日志解析方便 |
反向解析的安全风险:PTR 记录由地址所有者控制,不能作为身份认证。日志里用 NI_NUMERICHOST,需要展示主机名时再单独做「正向确认」(查到的名字再正向解析回同一地址)。
2.16.15 UDP 与 UNIX domain 的边界细节
这一节回答:UDP 的 recvfrom 返回 0 到底是不是 EOF,sendto 的 EMSGSIZE 从哪里来,UNIX domain 的抽象名字为什么不能用 strlen。
UDP 的四个系统调用与 flags
#include <sys/socket.h>
ssize_t sendto(int fd, const void *buf, size_t len, int flags,
const struct sockaddr *dest, socklen_t addrlen);
ssize_t recvfrom(int fd, void *buf, size_t len, int flags,
struct sockaddr *src, socklen_t *addrlen);
ssize_t sendmsg(int fd, const struct msghdr *msg, int flags);
ssize_t recvmsg(int fd, struct msghdr *msg, int flags);
| flag | 用于 | 语义 |
|---|---|---|
MSG_DONTWAIT | send/recv | 本次调用非阻塞,不改 fd 状态 |
MSG_PEEK | recv | 看一眼数据但不从队列移除 |
MSG_TRUNC | recv | 返回数据报的真实长度,即使缓冲区不够 |
MSG_WAITALL | recv | 对 UDP 无效(数据报要么整条、要么截断) |
MSG_ERRQUEUE | recv | 读错误队列(ICMP 错误、IP_RECVERR) |
MSG_CMSG_CLOEXEC | recvmsg | 收到的 SCM_RIGHTS fd 自动设 CLOEXEC |
MSG_NOSIGNAL | send(TCP) | 不产生 SIGPIPE;UDP 无此问题 |
recvfrom 的返回值有三种,含义完全不同:
> 0:收到一个数据报,长度是返回值;0:收到一个零长度数据报(合法),不是 EOF;-1:错误,看errno。
UDP 没有「连接关闭」的概念,所以 recvfrom 永远不会用 0 表示 EOF。把 0 当 EOF 退出循环,会在对方发空数据报时误关连接。
UDP 的 errno
| errno | 触发 | 说明 |
|---|---|---|
EAGAIN/EWOULDBLOCK | 非阻塞,接收队列空/发送缓冲区满 | 等 poll |
EINTR | 被信号打断 | 重试 |
EMSGSIZE | 数据报超过路径 MTU 且设了 IP_MTU_DISCOVER=IP_PMTUDISC_DO,或超过 65507 | 分片或减小消息 |
ECONNREFUSED | connect 过的 UDP 收到 ICMP port unreachable | 异步错误,下一次 send/recv 才返回 |
EACCES | 发广播未设 SO_BROADCAST | setsockopt(SO_BROADCAST) |
EDESTADDRREQ | 未 connect 且 sendto 没给目的地址 | 补地址 |
EISCONN | 已 connect 又用 sendto 指定目的地址 | 用 send 或先断开 |
ENOBUFS/ENOMEM | 内核缓冲区不足 | 降载;查 SndbufErrors |
EPERM | 防火墙/LSM 拒绝 | 查 iptables -j REJECT |
UDP 的最大负载:IPv4 是 65507 字节(65535 − 20 IP 头 − 8 UDP 头),IPv6 是 65527 字节(65535 − 8 UDP 头)。超过这个长度 sendto 返回 EMSGSIZE;小于等于这个长度但在路径 MTU 以下,可能被 IP 分片。
截断、丢包与 SO_RXQ_OVFL
recvfrom(fd, buf, 1024, 0, ...) 收到 2000 字节的数据报时,返回 1024,剩余 976 字节被丢弃。要区分「数据报就是 1024 字节」与「被截断」,用 recvmsg + MSG_TRUNC:
char buf[1024];
struct iovec iov = { .iov_base = buf, .iov_len = sizeof buf };
struct msghdr msg = { .msg_iov = &iov, .msg_iovlen = 1 };
ssize_t n = recvmsg(fd, &msg, MSG_TRUNC);
if (n > (ssize_t)sizeof buf)
fprintf(stderr, "truncated: real=%zd, kept=%zu\n", n, sizeof buf);
接收缓冲区满时,UDP 直接丢包,不通知发送方。丢包计数在 /proc/net/snmp 的 Udp: 行:
grep '^Udp:' /proc/net/snmp
# Udp: InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrors InCsumErrors
RcvbufErrors 增长说明应用读得太慢或 SO_RCVBUF 太小;Linux 3.16+ 可用 SO_RXQ_OVFL 在辅助数据里附带丢包计数。
UDP 的 connect
对 UDP 调用 connect 不发送任何报文,只做两件事:
- 记录默认目的地址,之后可以用
send/write; - 让内核把该目的地址的 ICMP 错误(port unreachable 等)作为
ECONNREFUSED返回给应用。
UDP 的 connect 可以多次调用改目的地址;也可以用一个 AF_UNSPEC 的 connect 断开。它不会产生 EINPROGRESS,因为不涉及握手。
UNIX domain 的抽象名字与 socketpair
抽象命名空间的名字不是文件系统路径,长度由 addrlen 决定:
struct sockaddr_un addr = {0};
addr.sun_family = AF_UNIX;
const char name[] = "demo.worker"; /* 不含结尾 \0 */
addr.sun_path[0] = '\0'; /* 抽象命名空间标志 */
memcpy(addr.sun_path + 1, name, sizeof name - 1);
socklen_t len = offsetof(struct sockaddr_un, sun_path) + 1 + sizeof name - 1;
bind(fd, (struct sockaddr *)&addr, len);
规则:
- 名字从
sun_path + 1开始,可以包含\0,所以不能用strlen; addrlen必须精确,多一个字节就多一个\0,名字就变了;- 不需要
unlink,进程退出自动释放; - 不受文件系统权限约束,但受
SO_PASSCRED/SO_PEERCRED的身份检查保护。
路径名 socket 则在文件系统里创建一个 S_IFSOCK 文件,权限由 umask 与 chmod 决定。服务端启动时先 unlink 旧文件,但这里有竞态:两个实例同时 unlink + bind,后一个会把前一个的 socket 文件删掉。稳妥做法是用 flock 锁文件,或让 systemd 的 socket activation 保证单实例。
socketpair 创建一对已连接的匿名 socket:
int sv[2];
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0, sv) == -1)
perror("socketpair");
| type | 语义 | 典型用途 |
|---|---|---|
SOCK_STREAM | 字节流,和 TCP 一样没有边界 | 父子进程双向管道 |
SOCK_DGRAM | 保留边界,可靠(本机不丢) | 简单消息传递 |
SOCK_SEQPACKET | 保留边界 + 有序 + 可靠 | 需要消息边界的本机 RPC |
socketpair 的 type 也接受 SOCK_CLOEXEC/SOCK_NONBLOCK;它比「两条 pipe」少一次 pipe,而且天然双向。SCM_RIGHTS 传 fd 必须用 UNIX domain socket(socketpair 也可以),因为只有 UNIX domain 的 sendmsg 实现了文件描述符传递。
SCM_RIGHTS 一次最多传 SCM_MAX_FD(253)个 fd;接收方拿到的每个 fd 都是 dup 出来的新描述符,必须自己 close。SO_PASSCRED 打开后,接收方还能收到 SCM_CREDENTIALS(发送方的 pid/uid/gid),这是本机服务做认证的另一种手段,细节见 2.17.7。
2.16.16 内核路径:从 write 到网卡,从网卡到 read
这一节回答:一次 write(sockfd, ...) 在内核里经过哪些函数、哪些队列,最后怎么变成网线上的电信号;反向路径又怎么把包送到 read 的缓冲区。
发送路径
write(2)
└─ vfs_write() fs/read_write.c
└─ sock_write_iter() net/socket.c
└─ sock_sendmsg() net/socket.c
└─ inet_sendmsg() net/ipv4/af_inet.c
└─ tcp_sendmsg() net/ipv4/tcp.c
└─ tcp_sendmsg_locked()
├─ tcp_stream_alloc_skb() 分配 skb
├─ skb_add_data_nocache() 用户数据拷进 skb
└─ tcp_push()
└─ __tcp_push_pending_frames()
└─ tcp_write_xmit() net/ipv4/tcp_output.c
├─ tcp_transmit_skb() 填 TCP 头
└─ ip_queue_xmit() 填 IP 头
└─ ip_local_out()
└─ dst_output()
└─ ip_output()
└─ dev_queue_xmit() net/core/dev.c
├─ qdisc enqueue
└─ sch_direct_xmit()
└─ dev_hard_start_xmit()
└─ ndo_start_xmit() 驱动
这条路径上有三个「可能停住」的点:
- 发送缓冲区满:
tcp_sendmsg_locked发现sk_wmem_queued超过sk_sndbuf,阻塞 socket 睡眠等 ACK 释放空间,非阻塞返回EAGAIN; - 拥塞窗口/接收窗口不足:数据留在
sk_write_queue,tcp_write_xmit只发窗口允许的部分; - qdisc 队列满:
dev_queue_xmit返回NET_XMIT_DROP,包被丢弃,TCP 靠重传恢复。
tcp_sendmsg_locked 的关键动作(Linux 6.8,节选):
/* net/ipv4/tcp.c */
int tcp_sendmsg_locked(struct sock *sk, struct msghdr *msg, size_t size)
{
...
while (msg_data_left(msg)) {
...
skb = tcp_stream_alloc_skb(sk, sk->sk_allocation, ...);
...
copy = min_t(int, msg_data_left(msg), skb_availroom(skb));
err = skb_add_data_nocache(sk, skb, &msg->msg_iter, copy);
...
tcp_push(sk, flags, mss_now, tp->nonagle, size_goal);
}
...
}
skb_add_data_nocache 把用户态数据拷到 skb 的线性区或分片页;tcp_push 决定是否立刻发(受 Nagle、TCP_CORK、MSG_MORE 影响)。write 返回成功只表示数据进了发送缓冲区,不表示对端收到——这是 TCP 与「写到文件」最大的语义差异之一。
接收路径
网卡 DMA → RX ring
└─ 硬中断 → napi_schedule()
└─ 软中断 net_rx_action() net/core/dev.c
└─ napi_poll() 驱动 poll
└─ napi_gro_receive()
└─ netif_receive_skb()
└─ ip_rcv() net/ipv4/ip_input.c
└─ ip_local_deliver()
└─ tcp_v4_rcv() net/ipv4/tcp_ipv4.c
└─ tcp_rcv_established() net/ipv4/tcp_input.c
├─ tcp_data_queue() 挂到 sk_receive_queue
└─ sk_data_ready() sock_def_readable()
└─ wake_up_interruptible_sync_poll()
└─ 唤醒 epoll/read 等待者
应用侧:
read(2)
└─ sock_read_iter()
└─ sock_recvmsg()
└─ inet_recvmsg()
└─ tcp_recvmsg()
└─ tcp_recvmsg_locked()
├─ skb_copy_datagram_iter() 从 skb 拷到用户缓冲区
└─ tcp_cleanup_rbuf() 更新窗口,可能发 ACK
关键点:
- 数据到达由软中断完成,
read只负责从已排队的 skb 拷贝;sk_data_ready唤醒sk_wq,epoll通过poll_wait挂同一个队列;tcp_cleanup_rbuf在应用读走后更新窗口,不读则窗口降到 0;SO_RCVLOWAT控制唤醒阈值(默认 1)。
2.16.17 三个完整程序:地址枚举、accept 防护、UDP 边界
这一节给三个可以直接编译的程序。它们分别验证:getaddrinfo 的返回结构、EMFILE 下的 accept 恢复、UDP 的截断与零长度数据报。
程序一:枚举 getaddrinfo 的全部结果
/* addr_dump.c:打印 getaddrinfo 返回的每个地址 */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <netdb.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
static void die_gai(const char *what, int rc)
{
fprintf(stderr, "%s: %s\n", what, gai_strerror(rc));
exit(EXIT_FAILURE);
}
int main(int argc, char **argv)
{
if (argc < 3) {
fprintf(stderr, "usage: %s host service [passive|numeric|addrconfig]\n",
argv[0]);
return EXIT_FAILURE;
}
int flags = AI_CANONNAME;
if (argc > 3 && strcmp(argv[3], "passive") == 0)
flags |= AI_PASSIVE;
if (argc > 3 && strcmp(argv[3], "numeric") == 0)
flags |= AI_NUMERICHOST | AI_NUMERICSERV;
if (argc > 3 && strcmp(argv[3], "addrconfig") == 0)
flags |= AI_ADDRCONFIG;
struct addrinfo hints = {0};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = flags;
struct addrinfo *res = NULL;
int rc = getaddrinfo(argv[1], argv[2], &hints, &res);
if (rc != 0)
die_gai("getaddrinfo", rc);
int i = 0;
for (struct addrinfo *ai = res; ai != NULL; ai = ai->ai_next, i++) {
char host[NI_MAXHOST] = "?";
char serv[NI_MAXSERV] = "?";
if (getnameinfo(ai->ai_addr, ai->ai_addrlen,
host, sizeof host, serv, sizeof serv,
NI_NUMERICHOST | NI_NUMERICSERV) != 0) {
strcpy(host, "?");
strcpy(serv, "?");
}
printf("%d family=%d socktype=%d proto=%d addrlen=%u %s:%s canon=%s\n",
i, ai->ai_family, ai->ai_socktype, ai->ai_protocol,
(unsigned)ai->ai_addrlen, host, serv,
ai->ai_canonname ? ai->ai_canonname : "-");
}
freeaddrinfo(res);
return i > 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g addr_dump.c -o addr_dump
./addr_dump example.com https
./addr_dump 127.0.0.1 8080 numeric
./addr_dump example.com 443 addrconfig
程序二:EMFILE 下仍能 accept 的服务端
/* tcp_guard_server.c:4 字节长度前缀 echo,处理 EMFILE/短读/超时 */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netdb.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#define PORT "12349"
#define BACKLOG 8
#define MAX_FRAME (64 * 1024)
static volatile sig_atomic_t stop;
static int reserve_fd = -1;
static void on_sigint(int sig) { (void)sig; stop = 1; }
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
static int read_full(int fd, void *buf, size_t n)
{
size_t off = 0;
char *p = buf;
while (off < n) {
ssize_t r = read(fd, p + off, n - off);
if (r == -1) {
if (errno == EINTR)
continue;
return -1;
}
if (r == 0)
return off == 0 ? 0 : -1; /* 边界 EOF / 中途截断 */
off += (size_t)r;
}
return 1;
}
static int write_full(int fd, const void *buf, size_t n)
{
size_t off = 0;
const char *p = buf;
while (off < n) {
ssize_t w = send(fd, p + off, n - off, MSG_NOSIGNAL);
if (w == -1) {
if (errno == EINTR)
continue;
return -1;
}
off += (size_t)w;
}
return 0;
}
static void handle(int cfd)
{
struct timeval tv = { .tv_sec = 3, .tv_usec = 0 };
setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
for (;;) {
uint32_t netlen;
int rc = read_full(cfd, &netlen, sizeof netlen);
if (rc == 0)
return;
if (rc == -1) {
perror("read header");
return;
}
uint32_t len = ntohl(netlen);
if (len > MAX_FRAME) {
fprintf(stderr, "frame too large: %u\n", len);
return;
}
unsigned char *frame = malloc(4 + (size_t)len);
if (frame == NULL)
return;
memcpy(frame, &netlen, 4);
if (read_full(cfd, frame + 4, len) != 1) {
free(frame);
return;
}
if (write_full(cfd, frame, 4 + (size_t)len) == -1) {
free(frame);
return;
}
free(frame);
}
}
int main(void)
{
struct sigaction sa = {0};
sa.sa_handler = on_sigint;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGINT, &sa, NULL) == -1)
die("sigaction");
reserve_fd = open("/dev/null", O_RDONLY | O_CLOEXEC);
if (reserve_fd == -1)
die("open reserve");
int lfd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (lfd == -1)
die("socket");
int one = 1;
if (setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one) == -1)
die("SO_REUSEADDR");
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons(12349);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(lfd, (struct sockaddr *)&addr, sizeof addr) == -1)
die("bind");
if (listen(lfd, BACKLOG) == -1)
die("listen");
printf("guard server on %s\n", PORT);
fflush(stdout);
while (!stop) {
struct sockaddr_storage peer;
socklen_t plen = sizeof peer;
int cfd = accept4(lfd, (struct sockaddr *)&peer, &plen, SOCK_CLOEXEC);
if (cfd == -1) {
if (errno == EINTR)
continue;
if (errno == EMFILE) {
close(reserve_fd);
cfd = accept(lfd, NULL, NULL);
if (cfd != -1)
close(cfd);
reserve_fd = open("/dev/null", O_RDONLY | O_CLOEXEC);
fprintf(stderr, "EMFILE: dropped one connection\n");
continue;
}
perror("accept4");
break;
}
handle(cfd);
close(cfd);
}
close(lfd);
close(reserve_fd);
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g tcp_guard_server.c -o tcp_guard_server
./tcp_guard_server
# 另一个终端:先发一个 5 字节帧,再发一个 70000 字节的超限长度头
printf '\x00\x00\x00\x05hello' | nc 127.0.0.1 12349
printf '\x00\x01\x11\x70' | nc -q1 127.0.0.1 12349
预期:第一个连接回 hello;第二个连接被服务端断开并打印 frame too large: 70000。ulimit -n 8 后并发连接可观察到 EMFILE 分支。
程序三:UDP 的零长度、截断与真实长度
/* udp_boundary.c:发送三个数据报,观察 recvmsg 的返回值与 MSG_TRUNC */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#define BIG 2000
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
int main(void)
{
int rfd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0);
if (rfd == -1)
die("socket");
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = 0;
if (bind(rfd, (struct sockaddr *)&addr, sizeof addr) == -1)
die("bind");
socklen_t alen = sizeof addr;
if (getsockname(rfd, (struct sockaddr *)&addr, &alen) == -1)
die("getsockname");
pid_t pid = fork();
if (pid == -1)
die("fork");
if (pid == 0) {
int sfd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0);
if (sfd == -1)
die("socket sender");
unsigned char big[BIG];
memset(big, 'x', sizeof big);
if (sendto(sfd, "", 0, 0, (struct sockaddr *)&addr, sizeof addr) < 0)
die("send zero");
if (sendto(sfd, big, sizeof big, 0, (struct sockaddr *)&addr, sizeof addr) < 0)
die("send big");
if (sendto(sfd, "ten bytes!", 10, 0, (struct sockaddr *)&addr, sizeof addr) < 0)
die("send ten");
close(sfd);
_exit(0);
}
char buf[1024];
for (int i = 0; i < 3; i++) {
struct iovec iov = { .iov_base = buf, .iov_len = sizeof buf };
struct msghdr msg = { .msg_iov = &iov, .msg_iovlen = 1 };
ssize_t n = recvmsg(rfd, &msg, MSG_TRUNC);
if (n == -1) {
perror("recvmsg");
break;
}
if (n == 0)
printf("datagram %d: zero-length\n", i + 1);
else if (n > (ssize_t)sizeof buf)
printf("datagram %d: real=%zd kept=%zu (truncated)\n",
i + 1, n, sizeof buf);
else
printf("datagram %d: len=%zd\n", i + 1, n);
}
close(rfd);
int status;
if (waitpid(pid, &status, 0) == -1)
die("waitpid");
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g udp_boundary.c -o udp_boundary
./udp_boundary
datagram 1: zero-length
datagram 2: real=2000 kept=1024 (truncated)
datagram 3: len=10
关键行:recvmsg(..., MSG_TRUNC) 返回真实长度 2000,但只往 buf 里放了 1024 字节;如果不带 MSG_TRUNC,返回值就是 1024,无法区分「本来 1024」和「被截断」。
2.16.18 实验:accept 队列溢出与握手抓包
实验一:把 accept 队列压满
用 Python 起一个「只 listen、不 accept」的服务端,队列长度设为 1:
python3 - <<'PY'
import socket, time
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 12345))
s.listen(1)
print("listening, sleeping 30s", flush=True)
time.sleep(30)
PY
另开终端,发起 5 条连接并保持:
for i in $(seq 1 5); do
(exec 3<>/dev/tcp/127.0.0.1/12345; sleep 10) &
done
ss -lnt 'sport = :12345'
nstat -az TcpExtListenOverflows TcpExtListenDrops
预期输出:
State Recv-Q Send-Q Local Address:Port
LISTEN 1 1 127.0.0.1:12345
TcpExtListenOverflows 3 0.0
TcpExtListenDrops 3 0.0
Recv-Q=1 表示 accept 队列已满(backlog=1),Send-Q=1 是上限;ListenOverflows 增加说明后续 ACK 被丢弃。此时客户端不会立刻收到 ECONNREFUSED,而是连接建立变慢或超时——这正是「服务端 accept 慢」与「端口没开」的区别。
实验二:用 strace 与 tcpdump 看握手和关闭
三个终端分别执行:
strace -f -e trace=network -o /tmp/tcp.log ./tcp_echo_server
sudo tcpdump -i lo -nn -vv 'tcp port 12345' -w /tmp/tcp.pcap
printf 'hello\n' | nc 127.0.0.1 12345
ss -tanp 'sport = :12345 or dport = :12345'
预期 tcpdump 文本(省略窗口与校验和):
IP 127.0.0.1.53124 > 127.0.0.1.12345: Flags [S], seq ...
IP 127.0.0.1.12345 > 127.0.0.1.53124: Flags [S.], seq ... ack ...
IP 127.0.0.1.53124 > 127.0.0.1.12345: Flags [.], ack ...
IP 127.0.0.1.12345 > 127.0.0.1.53124: Flags [P.], length 6
IP 127.0.0.1.53124 > 127.0.0.1.12345: Flags [F.], seq ...
IP 127.0.0.1.12345 > 127.0.0.1.53124: Flags [F.], seq ... ack ...
读法:
[S]/[S.]/[.]是三次握手;[P.]是带数据的包,length 6对应hello\n;[F.]是 FIN,两个方向各一次,最后[.]是最后一个 ACK;- 若看到
[R]而不是[F.],说明有一方在未读完数据时close,或SO_LINGER设了 0。
2.16.19 陷阱清单:十二个现场
- 「
shutdown(SHUT_RD)会让对端收到 RST」——Linux 的shutdown(SHUT_RD)只是让本端read返回 0,之后到达的数据被 ACK 后丢弃;只有在本端接收队列还有未读数据时close,才容易触发 RST。原 2.16.9 表中「对端继续写会收到 RST」的说法不准确,以此处为准。 - 「
recvfrom返回 0 是 EOF」——UDP 的 0 是零长度数据报;UDP 没有 EOF。TCP 的 0 才是对端关闭写方向。 - 「
accept会继承监听 fd 的O_NONBLOCK」——不会。必须用accept4(..., SOCK_NONBLOCK)或再fcntl。 - 「设了
SO_REUSEADDR就能起两个同端口实例」——不能。两个LISTEN同地址仍需SO_REUSEPORT。 - 「
SO_REUSEPORT可以随便绑」——要求同 UID、同 family/type/protocol、同地址;否则EADDRINUSE/EINVAL。 - 「
listen(fd, 0)表示禁止连接」——backlog 0 不是开关,内核实现可能仍允许一条;限流要在应用层做。 - 「
getaddrinfo的第一个结果一定最快」——顺序由 RFC 6724 与gai.conf决定,应用应遍历尝试。 - 「
AI_PASSIVE对客户端也适用」——客户端传了非 NULL host 时AI_PASSIVE基本无效;它只影响node == NULL的通配地址。 - 「
AI_ADDRCONFIG在容器里没问题」——只有 loopback 的容器可能查不到 IPv6/IPv4;调试时先去掉。 - 「
inet_ntoa可以多线程用」——它返回静态缓冲区;用inet_ntop。 - 「
sizeof(struct sockaddr)可以当addrlen」——只有 16 字节,IPv6 需要 28;必须传具体结构大小。 - 「
close会立刻发 FIN」——只要还有dup/fork的引用,连接就保持;shutdown影响所有引用,close只减引用计数。
2.16.20 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说出 socket 与普通文件的三个差异 | lseek、EOF、短读 | 2.16.1 |
正确填充 sockaddr_in | 端口/地址都用网络字节序 | 2.16.2 |
| 解释 backlog 与两个队列 | 能说出 ss -lnt 的 Recv-Q | 2.16.3 |
用 accept4 设置 CLOEXEC/NONBLOCK | 知道新 fd 不继承 O_NONBLOCK | 2.16.4 |
| 写出处理短读短写的 echo | 代码能编译并通过 nc 测试 | 2.16.5 |
说出 SOCK_NONBLOCK/SOCK_CLOEXEC 的原子性 | 能在 socket/accept4 里用对 | 2.16.10 |
解释 SO_REUSEPORT 的四个条件 | 同 UID、同地址、同 family/type | 2.16.11 |
用 getaddrinfo 支持 IPv4/IPv6 | 能遍历结果并 freeaddrinfo | 2.16.6 |
| 区分 TCP 与 UDP 的边界语义 | 能说出 UDP 截断行为 | 2.16.7 |
解释 SO_REUSEADDR 与 TIME_WAIT | 知道它不解决 SO_REUSEPORT 的问题 | 2.16.9 |
Q1 服务端 bind 返回 EADDRINUSE,但 ss -lnt 看不到同端口监听。最可能的原因是什么?怎么解决?
Q2 accept 返回的新 fd 是非阻塞的吗?如果监听 fd 设了 O_NONBLOCK 呢?
Q3 客户端 connect 返回 EINTR,可以直接重试 connect 吗?为什么?
Q4 UDP 服务端 recvfrom 的缓冲区是 1024 字节,客户端发了 2000 字节的数据报。recvfrom 返回什么?剩下的 976 字节去哪了?
Q5 为什么 shutdown(SHUT_WR) 之后还能 read?close 之后呢?
答案
A1 旧连接的 TIME_WAIT 占着四元组,ss -lnt 只显示 LISTEN,不显示 TIME_WAIT(用 ss -tan 才看得到)。解决:setsockopt(SO_REUSEADDR) 后再 bind,或等 2MSL(Linux 60 秒)。
A2 不是。Linux 上 accept 返回的新 fd 不继承监听 fd 的 O_NONBLOCK,新 fd 默认阻塞。监听 fd 是非阻塞的,只影响 accept 本身(没有连接时返回 EAGAIN)。要新 fd 非阻塞,用 accept4(..., SOCK_NONBLOCK),或之后再 fcntl。
A3 不能直接重试。阻塞 connect 被信号打断时,三次握手可能仍在后台进行;立即重试会得到 EALREADY。应改用 poll(POLLOUT) + getsockopt(SO_ERROR) 确认结果,或从一开始就用非阻塞 connect。
A4 返回 1024(缓冲区大小),数据报被截断,剩余 976 字节被丢弃,不会留在内核里等下次读。要检测截断,用 recvmsg 并检查 MSG_TRUNC 标志;要收完整数据报,缓冲区要足够大(UDP 最大 65507 字节)。
A5 shutdown(SHUT_WR) 只关闭写方向,读方向仍打开,所以能继续 read 直到对端关闭。close 释放 fd,之后不能再 read/write;如果还有其他 fd 引用同一 socket(dup/fork),连接不会立即关闭,但当前 fd 已经无效。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| K | 第 56 章 SOCKET:介绍 | 2.16.1–2.16.4 | 完整(socket/bind/listen/accept) |
| K | 第 57 章 SOCKET:UNIX DOMAIN | 2.16.8 | 完整(sockaddr_un、socketpair、抽象命名空间) |
| K | 第 58 章 SOCKET:TCP/IP 网络基础 | 2.16.2、2.16.7 | 完整(地址、端口、字节序、TCP/UDP) |
| K | 第 59 章 SOCKET:Internet Domain | 2.16.5–2.16.7 | 完整(getaddrinfo、TCP/UDP 示例) |
| C | 第 11 章 网络编程 | 2.16.1–2.16.7 | 完整(客户端-服务器模型、套接字接口) |
| W | 第 5 章 运输层 | 2.16.3、2.16.7 | 背景(TCP/UDP 语义、端口) |
| W | 第 6 章 应用层 | 2.16.6 | 背景(DNS 与名字解析) |
| N | 第 6 章 深度理解 TCP 连接建立过程 | 2.16.3 | 背景(SYN 队列、accept 队列、backlog) |
| K | 第 60 章 SOCKET:服务器设计 | 2.16.3、2.16.11、2.17.1.1 | 背景(backlog/SO_REUSEADDR;服务器模型见 2.17.1.1) |
| K | 第 61 章 SOCKET:高级主题 | 2.16.9、2.16.15 | 完整(shutdown、选项、辅助数据) |
| N | 第 3 章 内核如何与用户进程协作 | 2.16.16 | 背景(等待队列、唤醒路径) |
| N | 第 8 章 一台机器最多能支持多少条 TCP 连接 | 2.16.4 | 背景(fd 限制、EMFILE) |
下一站:2.17 套接字高级与网络编程 把 echo 服务端变成能承载真实协议的服务器:长度前缀分帧、部分读写、超时、心跳、优雅关闭,以及用
SCM_RIGHTS在进程间传递 fd。