1. 一场典型的多线程服务器事故现场
先看一段很常见的代码,很多刚开始写Linux并发服务器的同学,第一版往往长这样:
// 主线程 while (1) { int connfd = accept(listenfd, (struct sockaddr*)&cliaddr, &clilen); thread_pool_add_task(connfd); // 把 fd 丢进任务队列 } // 工作线程 void* worker(void* arg) { while (1) { int fd = task_queue_pop(); handle_client(fd); close(fd); } }看起来逻辑没毛病:主线程负责accept,工作线程负责处理。但跑起来之后,灾难就来了——客户端时不时收到乱数据、服务端偶尔对一个已经断开的连接执行read/write、甚至有一天你发现某个线程在处理别人的连接,数据串得莫名其妙。
我当年第一次遇到这堆问题的时候,第一反应是加锁、排查队列竞争。锁加了一圈,问题依旧。最后用gdb挂着看,才发现问题的核心根本不是锁,而是文件描述符的传递方式从一开始就错了。这个标题里说的“正确传递方式”,实际上包含两层意思:一是在多线程模型里,你传递的到底是什么;二是当你真的需要在不同进程之间传递fd时,又该怎么操作。
这篇文章我会从fd在内核里的本质讲起,把多线程服务器中连接对象该如何移交、任务队列该怎么设计、什么情况下才需要用到SCM_RIGHTS这一类进阶手段,全部串起来讲清楚。无论你是刚入门网络编程,还是已经在写生产级服务器,这篇都值得看完。
先记住一个结论:多线程之间传fd,看似传的是一个int,实际传的是一个资源的“所有权”。如果你只传了数字而没传所有权,就等于把一把钥匙复印件丢给同事,自己手里那把还留着,最后谁都说不清该由谁来锁门。
2. fd到底是什么:一张表格里的索引号,不是全局整数
2.1 文件描述符表是进程级的,线程是共享的
很多新手会下意识觉得fd就是一个“分配给客户端的编号”,类似数据库里的自增ID,所有线程都能拿它去操作socket。这个理解只对了一半。
在Linux内核里,每个进程都维护着一张文件描述符表(fd table),表中的每一项指向一个“打开文件描述”(open file description)结构。fd的int值就是这张表的数组下标。你调用的read(connfd, ...),内核做的事情是:拿着connfd去当前进程的fd table里找到对应的表项,然后顺着表项指向的open file description去操作真正的那条socket。
关键在于:同一个进程内的多个线程,共享同一张文件描述符表。也就是说,线程A拿到fd=7,线程B拿到fd=7,两个数字在各自线程里访问的是同一个socket,因为它们查的是同一张表。这跟多进程完全不一样,多进程各有各的表,fd=7在进程A和进程B里可以指代完全不同的东西。
这就引出一个容易被忽视的问题:既然多线程共享fd表,那“传fd给线程”这个动作,本质上不是传资源,而是通知另一个线程去使用一个本来就共享的资源。它不需要任何内核层面的传输机制,一个int丢过去,对方确实能用。
但能用不代表用得好。真正的隐患在于:谁对这个资源负责。
2.2 为什么裸传int偶尔正常,偶尔出错
假设主线程把connfd=7丢给工作线程之后,自己因为超时之类的逻辑把fd=7给close了。此时内核里这个socket的引用计数减一。如果工作线程还在read(7),会出现什么情况?
内核的fd table里,下标7指向的open file description已经被标记为关闭,但工作线程再read(7),实际上会拿到一个EBADF(bad file descriptor)错误。你以为只是报个错而已?没那么简单——fd值会被立刻复用。
当主线程再次accept得到一个新连接时,内核分配fd时可能会复用7这个号码。这时候工作线程的read(7)就不再是报错了,而是直接读到新客户端的数据。你会看到:明明连接的是A客户端,服务端却把数据发给了B客户端;B客户端收到的数据其实是A发来的。这种串包问题极难察觉,因为TCP层完全正常,没有任何错误码。
说白了,裸传int之所以“偶尔正常”,是因为在你close之前,工作线程已经把活干完了;偶尔出错,是因为close和read之间的竞争窗口被碰上了。这种非确定性的bug,一旦出现,轻则业务错乱,重则服务端崩溃,而且极难复现。
2.3 同进程传fd与跨进程传fd的本质区别
这里必须把两个概念分开:跨线程移交所有权和跨进程传递fd。
- 跨线程:同一个进程内,fd table共享,不需要任何特殊API。你只需要保证所有权清晰——由谁负责close、由谁负责在异常时回收。
- 跨进程:两个进程各有独立的fd table,直接把int从进程A发给进程B,进程B拿着这个数字去访问自己的fd table,大概率指向一个完全无关的文件,或直接EBADF。想让进程B也拥有对同一个socket的访问权,必须借助Unix域套接字上的SCM_RIGHTS辅助数据,让内核帮你把“引用”从进程A的fd table复制到进程B的fd table。
多线程服务器里踩的坑,90%是第一类——所有权不清晰。但很多人在网上查资料,搜来搜去搜到SCM_RIGHTS,以为要用这个来“传fd给线程”,这就是被带偏了。SCM_RIGHTS确实是传fd的正规途径,但它解决的是多进程间的资源共享问题,不是多线程间的工作分配问题。
3. 多线程服务器里传的是什么:连接对象,而不是裸fd
3.1 把“数字”升级为“对象”,才能承载生命周期
既然裸传int有那么多坑,怎么改?核心思路很简单:不要传int,要传一个包含了int的、有着明确生命周期的连接对象。
typedef struct conn { int fd; struct sockaddr_storage addr; socklen_t addr_len; uint64_t conn_id; int refcnt; // 引用计数 int closed; // 标记是否已关闭 // 业务相关字段 char read_buf[4096]; size_t read_len; // 时间戳,用于超时管理 time_t last_active; } conn_t;这样一个conn_t对象,承载的不仅仅是fd这个数字,还有连接来源、连接ID、缓冲状态、最后活跃时间等上下文。更重要的是,它能承载所有权语义:我们可以给对象加引用计数,可以加closed标记,可以在不同线程间安全地“借用”而不会误关闭。
当工作线程从队列里拿到的是conn_t*而不是int时,所有对fd的操作都必须经过这个对象——该检查closed就检查,该加引用计数就加。这样一来,就算主线程因为某种原因想提前断开这个连接,它也只会把closed置为1,而不会直接close(fd)。真正close的工作推迟到最后一个持有者释放引用时进行。
3.2 所有权移交的正确姿势:我接受,那么我负责
实践中最简单也最稳妥的模型就是:谁从队列里拿出连接,谁就完整负责这个连接的读、写、关闭。主线程accept之后,只负责把conn_t塞进队列,再也不能碰这个对象。
// 主线程 conn_t* conn = conn_new(connfd, &cliaddr, clilen); task_queue_push(work_queue, conn); // 工作线程 conn_t* conn = task_queue_pop(work_queue); // 从这里开始,这个conn完全属于当前线程 handle_client(conn); conn_destroy(conn); // 内部会close(conn->fd)很多踩坑案例里,问题不在于队列本身,而在于主线程“手贱”——accept之后还留着connfd变量,在超时管理逻辑里又碰了一下,或者干脆某个线程异常退出前没有统一清理,导致fd悬空。要根治,就得在代码层面定死规矩:移交之后,原持有者即刻失去对连接的所有权,连读它的fd都算越权。
我自己在项目里会加一个编译期可见的注释块,或者用一个状态机字段表示conn的owner是谁。调试的时候只要看owner字段就知道这个连接该由谁处理,大大减少了扯皮时间。
3.3 任务队列的设计:封装fd之外还要考虑竞态窗口
讲到这里,必须提一个非常容易踩的坑:主线程accept之后、把conn_t真正塞进队列之前,客户端可能已经断开了。此时conn_t里的fd对应的socket已经进入close状态,但工作线程还没拿到它,也不知道它已经失效。
如果只是传int,这个问题根本无法感知,因为fd值还在,read它才报错。但如果在连接对象里做文章,可以在push之前先尝试一次非阻塞零字节读或调用getsockopt(SO_ERROR)来探活。更专业的做法是:把fd设置为非阻塞,并且第一次读取放在工作线程里执行,利用read返回0或者EAGAIN来区分正常空读和连接断开。对于大多数业务场景,更实用的组合是:
- accept之后立刻把socket设为非阻塞;
- 将conn_t放入队列后,工作线程用poll/ epoll或简单的read循环去确认连接是否还有效;
- 如果read返回0或者错误码为ECONNRESET/ETIMEDOUT,直接销毁conn_t,不进入业务处理。
这样就把accept和连接失效检测之间的竞态窗口消解掉了。如果你用的是epoll做主循环,那个模型的思路会更加自然——主线程只负责把新连接的事件注册到epoll里,工作线程处理业务数据时,根本不碰fd。
3.4 引用计数与close的联动:最后一个释放者来做close
所有权清晰之后,还可能出现一种情况:同一连接被多个线程同时引用(例如一个线程在等待业务响应,另一个线程负责心跳超时断开)。这时就必须引入引用计数。
void conn_ref(conn_t* conn) { __sync_add_and_fetch(&conn->refcnt, 1); } void conn_unref(conn_t* conn) { if (__sync_sub_and_fetch(&conn->refcnt, 1) == 0) { close(conn->fd); free(conn); } }设计要点:close(fd)永远发生在refcnt归零的时候。任何线程想“断开”连接,不应直接close(fd),而是先标记closed=1,再调用conn_unref。这样可以保证即使还有别的线程正握着这个对象,它也不会读到一个已经被内核回收编号的fd。
我见过不止一个项目,因为某个线程在超时检查里直接close(conn->fd),导致其他线程还在拿着这个fd做write,结果把数据发给了完全不同的新连接。这个问题在并发量上来之后尤为致命——fd复用是内核非常频繁的操作,根本不是小概率事件。
4. 何时才需要“真正”传递fd:进程间SCM_RIGHTS完整拆解
4.1 多进程架构下的刚需场景
如果你只是写一个多线程的TCP服务器,到上一节为止的内容已经够用了。但有时候我们会遇到这类需求:一个守护进程采用多进程架构,由其中一个进程负责accept来连接,然后希望把它收到的连接交给另一个独立进程去处理。这时就必须跨进程传递fd。
典型应用场景包括:轻量级热升级(旧进程把现有连接传给新进程)、连接分发器(主进程accept后分发给多个子进程)、或者你把网络收发和业务处理放在不同的进程里做隔离。这些情况,靠普通write/read传int是绝对不可行的,必须动用Unix域套接字的辅助数据机制——SCM_RIGHTS。
4.2 SCM_RIGHTS的数据结构:cmsghdr 与辅助数据
先看发送方的代码,这是最完整的写法:
#include <sys/socket.h> #include <sys/uio.h> int send_fd(int unix_fd, int fd_to_send) { struct iovec iov[1]; struct msghdr msg; union { struct cmsghdr cm; char control[CMSG_SPACE(sizeof(int))]; } control_un; char dummy = 'D'; // 必须要带一个字节的普通数据,否则很多实现不触发辅助数据的处理 iov[0].iov_base = &dummy; iov[0].iov_len = sizeof(dummy); memset(&msg, 0, sizeof(msg)); msg.msg_iov = iov; msg.msg_iovlen = 1; // 初始化辅助数据缓冲区 memset(control_un.control, 0, sizeof(control_un.control)); msg.msg_control = control_un.control; msg.msg_controllen = sizeof(control_un.control); struct cmsghdr* cmptr = CMSG_FIRSTHDR(&msg); cmptr->cmsg_len = CMSG_LEN(sizeof(int)); cmptr->cmsg_level = SOL_SOCKET; cmptr->cmsg_type = SCM_RIGHTS; *(int*)CMSG_DATA(cmptr) = fd_to_send; return sendmsg(unix_fd, &msg, 0); }接收方基本对应:
int recv_fd(int unix_fd) { struct iovec iov[1]; struct msghdr msg; union { struct cmsghdr cm; char control[CMSG_SPACE(sizeof(int))]; } control_un; char dummy; iov[0].iov_base = &dummy; iov[0].iov_len = sizeof(dummy); memset(&msg, 0, sizeof(msg)); msg.msg_iov = iov; msg.msg_iovlen = 1; memset(control_un.control, 0, sizeof(control_un.control)); msg.msg_control = control_un.control; msg.msg_controllen = sizeof(control_un.control); if (recvmsg(unix_fd, &msg, 0) <= 0) { return -1; } struct cmsghdr* cmptr = CMSG_FIRSTHDR(&msg); if (cmptr != NULL && cmptr->cmsg_level == SOL_SOCKET && cmptr->cmsg_type == SCM_RIGHTS) { return *(int*)CMSG_DATA(cmptr); } return -1; }几个容易忽略的细节:
- 必须带一个字节的普通数据。SCM_RIGHTS的辅助数据是挂在IO操作上的,sendmsg时iov里的数据线程可以为空,但实践层面几乎都会附带一个哑字节。接收端recvmsg的返回值如果是0,通常意味着对端关闭,这种情况下别去解析辅助数据。
- 辅助数据的缓冲区大小,建议直接用
CMSG_SPACE(sizeof(int))来算,别手动写死。不同的平台对齐规则不一样,写死了容易在结构的尾巴上出错。 - 一个cmsghdr可以携带多个fd,CMSG_DATA实际上指向一个int数组。如果你要一次传多个fd,比如把socketpair的两端都传过去,那就在
CMSG_DATA(cmptr)位置连续放多个int。 - 接收方拿到fd之后,这个fd在接收进程里是“新建”的。它和发送方的原始fd指向内核中的同一个open file description,所以它们共享socket缓冲、偏移量等状态。两边对同一个socket的read/write操作是互相可见的,但两边的fd编号可能完全不同。
4.3 传递之后谁close:引用语义与风险
进程A把fd=7对应的socket传给进程B,进程B拿到后它的fd很可能等于3。此时进程A可以继续使用fd=7,也可以close(7)释放自己的引用。进程B只要不close(3),连接依然健在。
这个语义其实是Unix域套接字设计里的“继承”,跟fork继承fd表很类似,区别在于SCM_RIGHTS是运行时按需传递,不需要父子关系。内核通过open file description的引用计数来保证:只要还有一个进程持有该连接的fd,TCP连接就不会被真正释放。
这里要提醒一个风险点:传递后如果发送方立刻close,连接不会断,还能正常工作,但接收方必须负责后续的close。如果两边都不close,就变成了fd泄漏;如果两边同时close,也没问题——close只是减少引用计数,最后一个close才真正触发资源回收。利用这个特性,可以设计“连接烫手山芋”模式:主进程把连接传给子进程后立刻close自己的引用,所有权完全脱手,非常干净。
4.4 接收端忘记处理EMSGSIZE的风险
一个我实际遇到过的坑:接收方的msg_controllen给的缓冲区太小,比如只给了CMSG_SPACE(sizeof(short))却传了一个int。recvmsg会返回EMSGSIZE错误,辅助数据被截断,fd也不会出现在接收进程里。这种错误很难排查,因为sendmsg那边完全没有提示,数据其实发出去了。
处理这类问题,最稳的办法是把接收缓冲区的尺寸放大到CMSG_SPACE(sizeof(int) * 4)以上,不为别的,就为了给将来扩展留余地,同时彻底避开对齐和截断的边界问题。
5. 实战排查:fd泄漏与错误传递定位的完整思路
5.1 用/proc/pid/fd 观察文件描述符数量变化
无论你是哪种模型,第一步学会的技能都是查看进程持有的fd总数:
ls -l /proc/<pid>/fd | wc -l正常运行的服务器,这个数字应该在某条水平线上小幅波动。如果你发现它单调递增,几乎可以肯定是fd泄漏:要么某个连接close漏了,要么队列里的连接对象被创建后没人负责销毁。
还可以看具体是哪些fd异常:
ls -l /proc/<pid>/fd每一行都带有指向目标的符号链接,对于socket连接,会显示类似socket:[123456789]这样的内核inode号。如果你看到大量同一inode的socket一直不释放,那个就是泄漏的连接。
5.2 配合ss/lsof 快速定位连接状态
要确认一个fd对应的连接到底处于什么状态,用ss命令最直接:
ss -tnp | grep <pid>它会列出该进程所有TCP连接以及对应的fd编号,ESTABLISHED和CLOSE_WAIT的数量一目了然。当CLOSE_WAIT持续堆积时,通常意味着对端已经断开,但你的服务线程没有正确read到EOF并close,这往往和连接所有权混乱直接相关——某个线程以为别人会关,结果谁都没关。
5.3 复现“fd被复用”类串包问题的调试方法
这一类bug的特点是随机出现,难以复现。我的经验做法是给每一个连接对象分配一个自增的conn_id,并在所有日志里同时打印conn_id和fd:
log_info("conn %lu fd=%d recv data", conn->conn_id, conn->fd);一旦出现串包,就能从日志看到:同一条TCP连接上出现了两个不同的conn_id在交叉处理数据,或者某个conn_id的处理线程变了。这种日志设计成本极低,但排查问题的时候价值巨大。
另外,如果怀疑某个线程在close之后还碰fd,可以用一个简单手段让问题提前暴露:在测试环境里把/proc/sys/net/ipv4/ip_local_port_range的端口回收速度调快,同时使用SO_REUSEADDR和短连接压测,提高fd复用的频率,让隐性问题从“偶发”变成“高频”,大大加速定位。
5.4 工具链之外的代码审查要点
工具能帮我们发现问题,但根因还是要靠代码层面来杜绝。每次review多线程服务器的连接管理代码,我都会重点核查三个点:
- 每一处accept出来的fd,是否都能找到唯一的释放路径?如果一条连接既能被主线程close,又能被工作线程close,那就是定时炸弹。
- 所有close调用是否都发生在确定的owner线程里?跨线程close哪怕逻辑上没错,也会给后面的维护者挖坑。
- 出队后的连接对象,是否可能同时被两个线程持有?如果可能,有没有refcnt保护?
这些检查点不需要高深技术,纯粹靠代码设计规范约束。多线程并发服务器的稳定性,往往不是靠bug修出来的,而是靠一开始就把所有权规则定对,把代码结构设计得让错误无处藏身。
提示:如果你在写一个新的多线程服务器,我强烈建议从“一连接一线程”模型起步,把accept和业务处理放在同一个线程里,跑通后再考虑线程池和任务队列。别一开始就上最复杂的模型,并发坑叠加所有权坑时,调试成本会成倍上升。
说到底,文件描述符在Linux网络编程里从来不是一个普通整数。它是一把钥匙、一个资源句柄、一份承诺。搞清楚它在你设计的系统里由谁掌管、何时释放、怎样移交,这比任何高深的IO模型都更能决定你的服务器在流量高峰时是稳定服务还是连环崩溃。希望这篇文章能帮你把之前踩过的坑全部填平,也能让你在下次看到别人把裸fd往队列里一丢时,一眼就看出问题所在。