news 2026/10/18 21:34:13

多线程服务器文件描述符传递:从裸int到SCM_RIGHTS的正确姿势

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多线程服务器文件描述符传递:从裸int到SCM_RIGHTS的正确姿势

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来区分正常空读和连接断开。对于大多数业务场景,更实用的组合是:

  1. accept之后立刻把socket设为非阻塞;
  2. 将conn_t放入队列后,工作线程用poll/ epoll或简单的read循环去确认连接是否还有效;
  3. 如果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多线程服务器的连接管理代码,我都会重点核查三个点:

  1. 每一处accept出来的fd,是否都能找到唯一的释放路径?如果一条连接既能被主线程close,又能被工作线程close,那就是定时炸弹。
  2. 所有close调用是否都发生在确定的owner线程里?跨线程close哪怕逻辑上没错,也会给后面的维护者挖坑。
  3. 出队后的连接对象,是否可能同时被两个线程持有?如果可能,有没有refcnt保护?

这些检查点不需要高深技术,纯粹靠代码设计规范约束。多线程并发服务器的稳定性,往往不是靠bug修出来的,而是靠一开始就把所有权规则定对,把代码结构设计得让错误无处藏身。

提示:如果你在写一个新的多线程服务器,我强烈建议从“一连接一线程”模型起步,把accept和业务处理放在同一个线程里,跑通后再考虑线程池和任务队列。别一开始就上最复杂的模型,并发坑叠加所有权坑时,调试成本会成倍上升。

说到底,文件描述符在Linux网络编程里从来不是一个普通整数。它是一把钥匙、一个资源句柄、一份承诺。搞清楚它在你设计的系统里由谁掌管、何时释放、怎样移交,这比任何高深的IO模型都更能决定你的服务器在流量高峰时是稳定服务还是连环崩溃。希望这篇文章能帮你把之前踩过的坑全部填平,也能让你在下次看到别人把裸fd往队列里一丢时,一眼就看出问题所在。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 20:54:50

XXE漏洞实战:从文件读取到内网渗透的利用与防御

做授权渗透测试时&#xff0c;我有个习惯&#xff1a;只要接口里出现了XML字样&#xff0c;不管它带不带XXE三个字母&#xff0c;都会顺手测一下。原因很简单——XXE漏洞不是那种需要复杂业务逻辑才能触发的洞&#xff0c;它往往是一个解析器默认配置就坑了一个系统的洞。从一个…

作者头像 李华
网站建设 2026/10/18 11:20:35

NCS8801S芯片RGB/LVDS转EDP:选型、配置与调试实战指南

简介&#xff1a;NCS8801S 是一颗低功耗、高性能的 RGB/LVDS 转 eDP 桥接芯片&#xff0c;面向从事手机、平板、笔记本等移动设备显示方案设计的硬件工程师与嵌入式开发者。它可将单/双通道 LVDS 或 18/24bit RGB 信号转换为 1/2/4-lane eDP 输出&#xff0c;支持 1.62/2.7Gbps…

作者头像 李华
网站建设 2026/10/18 1:01:44

ProtoBuf转EmmyLua注解:让VS Code在Lua协议开发中实现类型补全

在游戏客户端开发里&#xff0c;用ProtoBuf定义协议、用Lua写业务逻辑的组合非常常见。可一旦proto里的message到了Lua侧&#xff0c;IDE完全不认识它&#xff0c;字段名、嵌套层级、枚举值全靠脑子记&#xff0c;写错只能等运行时报错。所以我把ProtoBuf自动转成EmmyLua注解AP…

作者头像 李华
网站建设 2026/10/18 0:48:12

diagram-design:用图即代码与图形语言构建可维护的图表体系

1. 从一张草图到一套系统&#xff1a;diagram-design 到底在解决什么问题第一次听到 diagram-design 这个词&#xff0c;很多人会下意识觉得它就是个“画图工具”或者“图表模板库”。但真正在项目里被图表折磨过的人会明白&#xff0c;它要解决的根本不是“怎么画”&#xff0…

作者头像 李华
网站建设 2026/10/18 1:01:47

从背八股到扛住连环追问:大厂面试追问的六个底层套路

很多同学备战后端面试&#xff0c;最喜欢干的一件事就是背八股。什么“MySQL为什么用B树”“Redis为什么快”“CAP定理怎么理解”&#xff0c;背得滚瓜烂熟&#xff0c;张口就来。但一到真实面试&#xff0c;尤其是大厂二面三面&#xff0c;往往不是被基础题难倒&#xff0c;而…

作者头像 李华
网站建设 2026/10/18 1:01:43

SpringBoot课程在线测评系统:从组卷到评分的完整实现方案

这个题目我前后帮人看过几个版本&#xff0c;自己也完整搭过一套用来做课程随堂测验的在线测评系统。先说结论&#xff0c;这类“SpringBoot课程在线测评系统”在计算机毕业设计里的人气一直很高&#xff0c;不是因为它代码多难写&#xff0c;而是它天然能拆出多个清晰的业务模…

作者头像 李华