ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux TCP Socket并发实战:多进程与多线程选型与避坑指南

Linux TCP Socket并发实战:多进程与多线程选型与避坑指南 写这篇的时候我刚把一台测试服务器上跑了一周的多进程echo服务换成多线程版。压测数据放在手边代码注释还热乎。上次写Socket编程实战一的时候聊的是单线程的阻塞模型很多朋友留言问“那并发怎么办”。正好借这个标题把Linux下TCP Socket编程里最经典的两条并发路线——多进程、多线程——完整地拆一遍。这篇不是贴API文档而是把我踩过的坑、压测时的数据、以及最后怎么取舍的思路全部摊开讲。不管是刚开始写网络服务的新手还是准备“抄作业”直接落地的老手都能从里面拿到能直接用的东西。先说清楚这篇的边界以Linux C语言为基准涉及的主要系统调用是socket、bind、listen、accept、fork、pthread_create这套组合。Java的线程模型、Python的GIL多线程、Go的goroutine不在讨论范围。理解了Linux底层这套最朴素的并发模型再去看任何上层语言的并发方案都会轻松很多。1. 并发方案的整体设计与思路拆解1.1 单线程服务器到底卡在哪在进入多进程和多线程之前得先搞清楚一个问题单线程阻塞模型为什么撑不住并发。以最典型的echo服务器为例核心代码就是accept、read、write循环。问题在于read和write在默认阻塞模式下会卡住当前线程直到对端有数据过来或者连接断开。这意味着什么如果客户端A连接上来之后不发数据服务器进程就阻塞在read上后面所有客户端都被挡在门外。TCP的accept调用本身也是这样一次只能处理一个连接请求。这就是经典的“串行处理”瓶颈——服务器的吞吐量完全取决于单个连接的处理速度而不是CPU核数。有人会说“那我用非阻塞IO加轮询不就行了”确实非阻塞IO配合select、poll、epoll是第三种方案而且很多人觉得是更“高级”的解法。但并发不等于只有事件驱动这一条路。在并发量不大、逻辑相对独立的场景下多进程和多线程方案有它不可替代的优势简单、直观、不需要维护复杂的状态机。这也是为什么在生产环境里很多老牌服务比如Apache的prefork模式、早期的vsftpd都走了这条路线。1.2 进程与线程的取舍逻辑多进程和多线程的核心思路其实一致主服务只负责接收连接拿到一个已经建立好的TCP连接后就把这个连接交给一个独立的执行单元去处理。区别在于这个执行单元是进程还是线程。打个比方。多进程就像开连锁店每家店都是独立的法人店面烧了不影响其他店但开店成本高多线程就像一家店里雇了一堆店员共享同一个仓库、同一本账本沟通方便成本低但一个人出错可能把仓库的货全弄乱。映射到技术层面关键差异有四个。第一地址空间进程之间相互隔离一个进程崩溃不会拖垮整个服务线程共享同一进程的地址空间一个线程野指针就能让整个进程崩掉。第二资源开销fork要复制页表、复制文件描述符表开销比pthread_create大不少。第三通信方式进程间通信要走管道、共享内存、消息队列这些IPC机制线程间直接读写共享变量就行。第四调度效率线程切换的开销比进程切换小因为不需要切换地址空间。这不是说谁绝对好而是要看业务场景。下面两节分别把两条路线的完整姿势讲透。2. 多进程并发方案从fork到完整可用的代码2.1 基础fork模型与代码骨架多进程方案的核心系统调用就一个——fork。它的行为很特殊调用一次返回两次。父进程拿到的是子进程的PID子进程拿到的是0。基于这个特性代码结构特别清晰#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include signal.h #include sys/wait.h #define SERVER_PORT 8888 #define BACKLOG 128 #define MAX_BUFF 1024 void sigchld_handler(int signo) { while (waitpid(-1, NULL, WNOHANG) 0) ; } int main(int argc, char *argv[]) { int listenfd, connfd; struct sockaddr_in servaddr, cliaddr; socklen_t clilen sizeof(cliaddr); listenfd socket(AF_INET, SOCK_STREAM, 0); if (listenfd 0) { perror(socket); exit(1); } int opt 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); memset(servaddr, 0, sizeof(servaddr)); servaddr.sin_family AF_INET; servaddr.sin_addr.s_addr htonl(INADDR_ANY); servaddr.sin_port htons(SERVER_PORT); if (bind(listenfd, (struct sockaddr *)servaddr, sizeof(servaddr)) 0) { perror(bind); exit(1); } if (listen(listenfd, BACKLOG) 0) { perror(listen); exit(1); } signal(SIGCHLD, sigchld_handler); while (1) { connfd accept(listenfd, (struct sockaddr *)cliaddr, clilen); if (connfd 0) { if (errno EINTR) continue; perror(accept); exit(1); } pid_t pid fork(); if (pid 0) { perror(fork); close(connfd); continue; } if (pid 0) { // 子进程 close(listenfd); char buff[MAX_BUFF]; int n; while ((n read(connfd, buff, sizeof(buff))) 0) { write(connfd, buff, n); } close(connfd); exit(0); } // 父进程 close(connfd); } return 0; }这份代码虽然是“教学版”但三个关键细节已经体现出实战意识父进程close(connfd)、子进程close(listenfd)、以及SIGCHLD信号处理。2.2 子进程生命周期管理SIGCHLD很多第一次写多进程服务的人都栽在同一个坑上服务器跑起来之后用ps一看满屏的defunct僵尸进程。这些僵尸进程还会越来越多直到把系统的进程表占满导致新的fork失败。原因在于子进程退出时内核并不会立刻把它从进程表里清掉而是要等父进程调用wait或waitpid来“收尸”。如果父进程不管子进程就变成僵尸进程状态标记为Z占用进程表条目。虽然它不占CPU和内存但进程表是有限资源。解决办法就是上面代码里注册的SIGCHLD信号处理函数。子进程退出时内核会给父进程发送SIGCHLD信号父进程在信号处理函数里调用waitpid回收子进程。这里有两个细节必须强调。第一为什么waitpid要用WNOHANG而且要用while循环因为信号处理函数执行期间如果正好有多个子进程同时退出标准信号是不排队的同一个信号只记录一次。用while循环配合WNOHANG可以一次性把能回收的子进程全部回收避免漏掉。第二为什么accept返回EINTR要continue因为SIGCHLD信号可能会打断阻塞在accept上的系统调用导致它返回-1并设置errno为EINTR。如果不处理主循环就直接exit(1)了服务就挂了。这是多进程方案里最隐蔽的一个坑也是网上很多“fork版服务器莫名其妙挂了”的根因。注意signal函数的语义在不同Unix版本上有差异System V和BSD的SA_RESTART行为不同。在Linux上signal默认不会自动重启被中断的系统调用。更可靠的做法是用sigaction显式设置SA_RESTART或者像代码里这样对EINTR做手动处理。我在生产环境里更推荐sigaction加SA_RESTART。2.3 两个经典坑文件描述符继承与accept惊群文件描述符继承的问题在不懂原理的时候很容易忽略。fork的子进程会复制父进程所有的文件描述符包括监听的listenfd。如果子进程里不把listenfd关掉会发生两件事一是这个子进程一直占用着监听端口如果它迟迟不退出你得有办法把它关掉二是更实际的影响——所有的子进程都持有listenfd当有新的连接到达时多个进程会同时阻塞在accept上都会收到通知这就是经典的多进程“惊群”thundering herd问题。在旧的Linux内核版本上多个进程同时调用accept阻塞在同一个监听套接字上时一个连接到来会让所有等待的进程都唤醒但只有其中一个能成功accept其余进程继续阻塞。这会造成不必要的上下文切换高并发场景下性能损失明显。但Linux 2.6.18以后内核给accept加了类似“锁”的机制同一时刻只会有一个进程被唤醒所以现代Linux上的多进程accept惊群问题已经基本不存在了前提是你用同一个listenfd。不过要说清楚fork之前创建的listenfd是共享的引用不是复制一份独立的监听。正因如此子进程里关掉listenfd并不会影响其他进程的监听能力因为内核里是同一个socket结构体的引用计数减一。**父进程必须close(connfd)**也值得展开解释。当fork之后内核的socket结构体引用计数是2父进程和子进程各持有一个描述符指向它。子进程里处理完读写之后调用close(connfd)引用计数减为1连接并不会真正关闭。父进程这边如果不关闭自己的那份connfd这条连接就永远不会被内核回收连接数会随着请求量一路涨上去——变成实际生产中最常见的“连接泄漏”。3. 多线程并发方案细节比进程更苛刻3.1 pthread_server的基础框架多线程方案的基础代码长这样#include stdio.h #include stdlib.h #include string.h #include unistd.h #include pthread.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define SERVER_PORT 8888 #define BACKLOG 128 #define MAX_BUFF 1024 void *thread_worker(void *arg) { int connfd *(int *)arg; free(arg); char buff[MAX_BUFF]; int n; while ((n read(connfd, buff, sizeof(buff))) 0) { write(connfd, buff, n); } close(connfd); return NULL; } int main(int argc, char *argv[]) { int listenfd; struct sockaddr_in servaddr; listenfd socket(AF_INET, SOCK_STREAM, 0); if (listenfd 0) { perror(socket); exit(1); } int opt 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); memset(servaddr, 0, sizeof(servaddr)); servaddr.sin_family AF_INET; servaddr.sin_addr.s_addr htonl(INADDR_ANY); servaddr.sin_port htons(SERVER_PORT); if (bind(listenfd, (struct sockaddr *)servaddr, sizeof(servaddr)) 0) { perror(bind); exit(1); } if (listen(listenfd, BACKLOG) 0) { perror(listen); exit(1); } while (1) { struct sockaddr_in cliaddr; socklen_t clilen sizeof(cliaddr); int connfd accept(listenfd, (struct sockaddr *)cliaddr, clilen); if (connfd 0) { perror(accept); continue; } int *pfd (int *)malloc(sizeof(int)); if (pfd NULL) { close(connfd); continue; } *pfd connfd; pthread_t tid; if (pthread_create(tid, NULL, thread_worker, pfd) ! 0) { perror(pthread_create); free(pfd); close(connfd); continue; } pthread_detach(tid); } return 0; }注意两个细节第一传给线程函数的参数用malloc动态分配而不是直接传connfd的地址或值原因后面展开第二创建线程后立即pthread_detach让线程结束的时候自动回收资源避免线程资源泄漏。3.2 线程安全、可重入与共享资源线程方案最大的风险点在于共享。因为所有线程共享同一个进程的地址空间一个线程里访问的全局变量、堆内存其他线程随时可能同时访问。如果两个线程同时操作同一个变量而没有同步机制轻则数据错乱重则直接段错误。还有一个容易被忽略的点很多C库函数不是线程安全的典型如strtok、localtime、rand。这类函数内部用了静态变量保存中间状态两个线程同时调用就会互相踩踏。解决方案是使用它们的可重入版本strtok_r、localtime_r、rand_r。这也引出一个工程习惯上的建议——在写多线程程序时尽量用带_r后缀的线程安全版本这是C语言为多线程程序专门准备的标准接口。再回到代码里为什么要malloc传参。如果你直接在循环里写pthread_create(tid, NULL, thread_worker, connfd)那么connfd是栈上的局部变量而多个线程拿到的是同一个地址。connfd的值还会在下一次循环中被覆盖。如果线程调度顺序不凑巧一个线程还没来得及取用这个值下一个连接的connfd就把同一个内存位置的旧值覆盖了。经典的并发bug就这样诞生了时好时坏排查起来特别头疼。3.3 线程数量的上限与选择线程方案唯一的“资源上限”比进程宽松但也不是无限。每个线程默认栈大小通常是8MBulimit -s可查。也就是说创建1000个线程光栈空间就要预留约8GB虚拟内存。64位系统上虚拟内存空间足够大所以数量上几百上千个通常没问题但真正限制你的是两点一是物理内存如果线程实际使用的栈空间超过分配量会触发段错误二是CPU核数线程数量远超过CPU核数时大量时间会花在线程切换上而不是业务处理上。所以“一连接一线程”的模型在高并发下不划算假设每个连接处理需要10毫秒单核每秒最多处理100个连接换成4核开4个线程就能做到400的吞吐但开400个线程反而因为上下文切换开销增加吞吐可能不升反降。最佳线程数大致是CPU核数 * (1 等待时间/计算时间)如果你的业务主要是IO等待线程数可以适当多一些如果是纯计算线程数就取核数。线程池就是对这个模型的一个修正预先创建N个线程连接来了放到队列里线程从队列取任务执行。这样避免了频繁创建销毁线程的系统调用开销也控制了线程总量。很多现代网络框架比如Java的Tomcat、Netty的work线程池底层思路都是这套。4. 多进程 vs 多线程一套可抄的选型方法论4.1 六个维度的对比很多文章会把多进程和多线程的对比写成“谁更优秀”的技术争论但实际做工程的人都知道关键是场景匹配。我做过的服务里两种方案都踩过坑也都有跑得很稳的。直接上结论表格维度多进程多线程隔离性进程间地址空间隔离一个崩溃不影响其他线程共享地址空间一个崩溃整个进程崩启动开销较大fork要复制页表和文件描述符较小创建线程的开销远低于进程切换成本需要切换地址空间成本高同一进程内切换成本低数据共享需要IPC机制复杂度高共享变量直接用但要加锁编程难度要处理信号、僵尸进程难度中等要处理锁、线程安全难度高适用场景业务逻辑独立、需要高隔离性、连接数中等业务逻辑对响应时间敏感、连接数多、可接受共享风险注意这里有个很容易被误解的常识很多人认为多线程一定比多进程快。结论在大多数场景下成立但多线程的“快”主要体现在线程创建和上下文切换上真正的瓶颈往往在锁竞争。多进程靠地址空间隔离换来天然的无锁环境通信走IPC就行在某些高并发高互不干扰的场景下反而表现更好。4.2 我的推荐与中间方案我给朋友做技术选型时的建议很简单如果你的服务逻辑里各个连接处理比较独立害怕一个bug把整个服务拖垮优先考虑多进程如果你对单连接响应延迟要求很高连接数又大且你有信心处理好线程安全和锁竞争问题多线程更合适。但说实话现在的生产环境已经很少有服务会直接裸写多进程或多线程来处理每个连接了更多是两者的组合或者再往上叠一层事件驱动模型比如epoll 线程池。这里我给一个折中方案进程组 线程池。外层用少量进程数量等于CPU核数每个进程内再开一组线程处理IO读写。这样既保留了进程级别的隔离性又享受了线程的轻量。很多开源中间件在设计上也是这个路子只是又叠加了事件驱动把线程模型变得更高效罢了。在我自己的服务器上最后落地的方案是先按CPU核数fork出4个进程每个进程里创建一个带20个工作线程的线程池实测压测效果比纯多进程或纯多线程都稳。5. 常见问题与排查技巧实录5.1 bind失败Address already in use与TIME_WAIT如果你用测试工具反复重启服务器会经常碰上这个错误bind: Address already in use。第一次没感觉第二次启动就报错。原因在于如果上一次服务进程处理过连接在进程退出时客户端连接占用的本地端口可能还处于TIME_WAIT状态内核默认不允许立即绑定同一个端口。解决办法是代码里那行setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt))。它的作用是告诉内核允许监听套接字绑定到处于TIME_WAIT状态的地址。如果不加这行开发调试时频繁重启服务会让人怀疑人生加了之后重启基本无感。但要注意SO_REUSEADDR不是万能的它解决的是TIME_WAIT状态下的监听复用不解决“另一个进程仍然在监听同一个端口”的情况。排查这类问题时我习惯用ss -lntp看一下端口被哪个进程占用lsof -i:端口也能快速定位占用者的PID。5.2 僵尸进程多进程服务器最容易翻的船用ps -ef | grep defunct能看到大量僵尸进程的时候说明你的服务没有正确回收子进程。排查优先级首先是确认信号处理函数有没有注册成功其次是确认SIGCHLD处理函数里用的是不是waitpid加WNOHANG。还有一种情况是信号处理函数被注册了但子进程退出的时间点恰好不在主进程阻塞accept期间而是主进程正在处理其他事情信号一直挂在信号队列里没有被及时处理看上去就像子进程一直没有被回收。这种情况下可以查一下是不是主进程里写了耗时很长的业务逻辑阻塞了信号处理函数的执行。如果怀疑是代码里自己关掉了信号也检查一下有没有在子进程里把SIGCHLD信号重新设为SIG_IGN。有些子进程如果自己也想收孙子进程会把SIGCHLD的默认行为改掉进而影响父进程的回收逻辑。5.3 多线程崩溃与栈溢出多线程程序崩溃时的排查方式完全不同。fork出来的子进程崩溃了父进程还能继续跑大不了重启这个子进程而多线程里一个线程段错误直接终止整个进程所有连接瞬间全断。线上服务如果用了多线程模型我会强烈建议在开发阶段就开启core dump崩溃时留现场再分析。线程栈溢出是另一个高频坑尤其在线程处理函数里递归调用或者用了很大的栈上数组时。我遇到过同事在线程函数里直接char huge_buf[10 * 1024 * 1024];线程默认栈8MB直接溢出程序莫名其妙崩在read系统调用之后。排查这类问题有两个办法一是别在栈上放超大数组改用malloc或mmap分配二是用pthread_attr_setstacksize显式调整线程栈大小创建线程时通过pthread_attr_t传进去。对于崩溃定位gdb直接附加到出问题的线程或者用addr2line解析core文件里的指令地址基本能快速锁定到具体函数的某一行。多线程和信号处理不同崩溃现场往往会有明确的线程号info threads切换过去看调用栈就行。5.4 压测中的常见误判用ab、wrk这类工具压测时有一个常见误区把并发数调到几万结果发现错误率很高就得出结论说服务不行。实际上压测工具本身可能已经先扛不住了或者触发系统级别的ulimit -n文件描述符限制。Linux默认单进程打开文件数通常是1024远超你的连接数时连接被拒就是必然结果。调ulimit -n是可以的但要注意fork的进程会继承父进程的文件描述符限制所以要么在启动脚本里统一设置要么在服务进程里调用setrlimit动态修改。这个细节不处理好即使代码写得再正确连接稍微一多也会被系统挡在门外。另外压测结果还要关注一个指标TIME_WAIT连接数。压测结束后立刻执行ss -tan | grep TIME_WAIT | wc -l如果数量巨大说明服务端主动关闭连接占主导。主动关闭的一方必须进入TIME_WAIT保证TCP可靠关闭这部分连接会占用端口资源2MSL时间Linux上通常60秒。你可以通过修改net.ipv4.tcp_tw_reuse和net.ipv4.tcp_fin_timeout来优化但修改内核参数前务必确认业务协议允许否则会引入乱序或重传问题。6. 一点实实在在的体会多进程和多线程这套并发模型放到今天看确实不“新”但它是网络编程的地基。理解了fork之后文件描述符的引用计数理解了线程栈和锁的代价再去看epoll、看Reactor、看Netty你都不会觉得那是什么黑魔法——它们本质上就是为了解决“频繁创建执行单元太贵”和“连接数太多调度不过来”这两个资源问题演化出来的。我自己的习惯是学习和调试阶段用多进程方案简单、出问题容易定位追求性能和稳定性的线上中等并发服务用进程池加工线程池的组合。如果你正在纠结选哪一条路建议先别急着在论坛上问方案先用手头的场景分别压一下看看哪个方式更贴合你的业务模型。代码不是越多越好而是越贴合场景越好。
返回列表