ARTICLE DETAIL

资讯详情

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

从零实现Linux多进程TCP并发服务器:原理、代码与排坑实战

从零实现Linux多进程TCP并发服务器:原理、代码与排坑实战 开篇先别急着写代码想清楚为什么是多进程说句实话现在一聊到Linux服务器编程很多人第一时间想到的是epoll、io_uring、Reactor模型这些高级货觉得多进程模型太老古董了。但我在实际项目里摸爬滚打这么多年接手的线上服务、写过的网络中间层、给嵌入式Linux设备做协议适配发现一个很扎心的事实绝大多数的互联网服务、内部工具、设备服务器其实根本用不着epoll一个干净利落的多进程模型能解决90%的问题而且它更简单、更不容易出bug、排查起来也更直观。这篇博文我就以从零实现一个TCP并发服务器为目标把Linux多进程服务器编程这条线上的所有关键点都拆开揉碎讲清楚从socket的基本API到fork()的进程模型从SIGCHLD收尸到SO_REUSEADDR的坑从一连接一进程到进程池预派生优化。看完之后你不仅能独立写出一个能扛住真实压力测试的多进程TCP服务器还能搞清楚这一套设计背后每个选择的原因。如果你正打算入门Linux网络编程或者备战面试、面试造火箭实际拧螺丝这篇文章应该能帮上忙。我自己最早接触多进程TCP服务端是在做嵌入式Linux的一个采集网关。当时的场景很简单终端设备通过TCP上报数据数量不大峰值几十个连接但每个连接要保持很久而且需要独立隔离。当时我第一个版本用单线程循环结果一个设备断连重传就能把整个服务卡死后来改成多进程整个世界清净了。现在回头看那次被逼无奈的改造反而是我把多进程模型吃透的开始。下面我先把设计思路和原理讲透再给你一套能跑的完整代码最后把压测和排坑的实战经验也一并交代了。1. 选型思考与整体设计思路1.1 为什么在并发场景里多进程依然是硬通货先把话放这儿多进程模型、多线程模型、事件驱动Reactor/Proactor模型三者没有绝对的谁替代谁只看你的业务场景允许牺牲什么。多进程模型的核心思想很朴素主进程只负责accept新连接每来一个连接就fork()一个子进程让子进程独占这个连接主进程继续回头等下一个。连接之间天然隔离——某个子进程崩溃、卡死、内存泄漏顶多影响它手里的那一个连接主进程和其他连接毫发无损。这句话就是多进程模型最值钱的地方。为什么我会在不少场景里优先选多进程隔离性与稳定性子进程之间各自拥有独立的地址空间一个子进程被业务逻辑搞崩segfault不会拖垮整个服务器。多线程模型里一个线程崩掉整个进程就没了这种惨痛教训我经历过不止一次。利用多核CPU子进程可以在不同CPU核心上并行运行没有GIL这种全局锁的牵制。对计算密集型的业务处理来说多进程的扩展性比多线程干净得多。编程模型简单不需要考虑互斥锁、条件变量、线程安全。子进程拿到连接描述符后整个世界是它一个人的读写逻辑完全可以按单线程的方式写这极大降低了心智负担。天然契合长连接业务如果你面对的场景是几十上百个长连接每个连接的逻辑很重且相对独立那么一连接一进程的模型几乎零额外开销地满足了需求。当然多进程模型也有原生的短板进程资源开销比线程大频繁fork()在极端高并发下会成为瓶颈进程间通信IPC比线程间共享内存要麻烦如果连接数上千每来一个连接就fork一次系统负担会急剧上升。所以常听人说多进程不适合高并发这话对也不对——准确说法是多进程不适合短连接高并发和海量连接场景但它特别适合中等数量的长连接重型业务逻辑场景。我后面会讲到进程池预派生那是在保留多进程优势的同时把fork开销压下去的成熟方案。所以我给初学者的建议是先老老实实把多进程模型吃透再去玩epoll。因为多进程模型涉及的知识点socket生命周期、文件描述符、信号、进程管理、TCP状态切换是Linux服务端编程的地基地基不牢玩什么模型都是空中楼阁。1.2 经典一连接一进程模型的完整运转链条看一张概念图手绘的没有工具画图意思到位就行主进程启动创建监听socket绑定端口进入accept死循环每拿到一个已连接的socket fd立马fork()子进程关闭对监听socket的引用专心处理这一个连接的业务父进程关闭对已连接fd的引用继续回到accept。子进程处理完客户端主动断开或业务结束exit退出父进程捕获SIGCHLD信号并回收。整个模型运转的关键点其实是文件描述符在fork前后的引用关系。很多人第一次写多进程服务端都会踩同一个坑fork之后不关闭不需要的fd。你以为子进程手里只有那个新连接其实它还继承了父进程的监听socket引用你以为父进程可以把连接交给子进程就不用管了但父进程如果一直握着那个已连接fd不关连接就永远不会真正释放。这些细节我后面会逐一扣。模型的适用边界也得说清楚建议把这种模式用在同时在线连接数在几百以内、单连接生命周期较长、业务处理比较重的服务上。比如设备接入网关、游戏房间服务器、某些内部长连接服务都是多进程模型的舒适区。如果哪天你的需求变成10万个客户端保持心跳连接这篇文章的模型就不太合适了那个量级需要换事件驱动思路。2. 动手前必会的TCP服务器原理储备2.1 三次握手、accept与listen backlogTCP三次握手是TCP协议栈层面完成的应用程序没有参与握手的过程但你作为服务器编写者必须理解握手和accept的配合关系。当客户端发起connect时内核协议栈会跟客户端完成SYN、SYNACK、ACK三轮交互然后在服务器的内核里建立起一个已完成握手的连接队列。你的服务程序调用accept()其实只是从这个队列里取一个已经建好的连接出来交给你的程序使用。所以你要搞清楚一个关键概念accept不参与三次握手握手在内核里就做完了。这也是为什么listen的backlog参数很重要——它决定了已完成握手但还没被accept取走的连接队列能排多长。如果服务进程处理得慢accept来不及取队列满了客户端connect就会表现为连接超时或者被拒。// listen的第二个参数backlog是重点 if (listen(listenfd, 64) 0) { perror(listen); exit(1); }在较新的Linux内核中backlog参数的语义已经是全连接队列的最大长度也就是已完成握手的连接能排多长。建议至少设到64或128除非你有把握服务的accept速度极快。我见过有人设成1结果压测一上来大量客户端connect失败排查半天才发现是backlog太小很经典的坑。还有一个关于accept的细节accept返回的是一个全新的文件描述符它和监听socket是两个不同的对象。监听socket只负责接受新连接已连接socket才承载实际的收发数据。理解这个区分你才能理解后面父子进程为什么要各关各的fd。2.2 socket、bind、listen、accept四个系统调用的关系这四个函数就是TCP服务端的骨架。socket()创建运维句柄bind()绑定地址和端口listen()把socket改成被动监听状态accept()从已完成握手的队列里取出连接。bind这一步有几个值得注意的细节端口号要去查清楚再用不要用已经被系统服务占用的端口。1024以下的一般需要root权限自己开发调试建议用8000以上的高位端口。地址用INADDR_ANY也就是0.0.0.0表示监听本机所有网卡。如果你只想让某个内网网卡提供服务可以用inet_pton精确指定。bind之后的端口是正在占用状态这个状态会持续到服务进程退出。如果服务崩溃后立刻重启你会遇到Address already in use这时候SO_REUSEADDR就派上用场了。accept会阻塞吗默认会。所以单线程模型里accept一阻塞后续所有连接都得排队等这就是为什么单线程TCP服务端无法真正处理并发。多进程模型做的事情其实很直白主进程阻塞在accept上一旦有连接就fork处理连接的是子进程主进程马上回到accept继续等下一个。3. 从零实现一个能跑起来的TCP多进程服务器3.1 基础框架socket、bind、listen、accept这一步不能省我直接给出一个完整的、能在Linux下编译运行的基础版本。不需要太多花哨的东西先把整个链路跑通后面再逐步加固。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include signal.h #include sys/types.h #include sys/socket.h #include sys/wait.h #include netinet/in.h #include arpa/inet.h #define PORT 8888 #define BACKLOG 64 int main() { int listenfd, connfd; pid_t pid; struct sockaddr_in server_addr, client_addr; socklen_t client_len sizeof(client_addr); // 1. 创建监听socket listenfd socket(AF_INET, SOCK_STREAM, 0); if (listenfd 0) { perror(socket); exit(1); } // 2. 设置SO_REUSEADDR避免TIME_WAIT导致的端口占用问题 int reuse 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, reuse, sizeof(reuse)); // 3. bind memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); server_addr.sin_port htons(PORT); if (bind(listenfd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(bind); exit(1); } // 4. listen if (listen(listenfd, BACKLOG) 0) { perror(listen); exit(1); } printf(Server listening on port %d, pid%d\n, PORT, getpid()); // 5. accept循环 while (1) { connfd accept(listenfd, (struct sockaddr *)client_addr, client_len); if (connfd 0) { if (errno EINTR) { continue; // 被信号中断重新accept } perror(accept); exit(1); } // 6. fork子进程处理连接 pid fork(); if (pid 0) { perror(fork); close(connfd); continue; } if (pid 0) { // 子进程处理连接 // 子进程不再需要监听socket立刻关闭 close(listenfd); handle_client(connfd); close(connfd); exit(0); } else { // 父进程关闭已连接fd继续accept close(connfd); } } close(listenfd); return 0; } // 连接处理函数这里只做回显 void handle_client(int fd) { char buf[1024]; int n; while ((n read(fd, buf, sizeof(buf))) 0) { // 回显给客户端 write(fd, buf, n); } if (n 0) perror(read); printf(Client disconnected, fd%d\n, fd); }这版代码是整个多进程服务器的骨架但我强烈建议你别直接拿它上生产因为少了信号处理和进程回收跑一会儿你就会看到一堆僵尸进程。别急这正好是下一节的内容。3.2 子进程的连接处理逻辑与fd关闭的坑上面代码里有一段很容易被忽略但极其重要的逻辑子进程close(listenfd)父进程close(connfd)。为什么必须是这个操作因为fork()会把父进程的整个地址空间、包括所有打开的文件描述符全部复制一份。listenfd和connfd的引用计数在fork之后都会1父子进程各自拥有一份指向同一个内核文件对象的引用。如果不做关闭子进程一直握着listenfd虽然它不会去accept但这个引用让listenfd永远不会被真正释放。哪天主进程要关掉重开你会发现listenfd迟迟释放不了。父进程一直握着connfd就算子进程处理完连接并close(connfd)由于父进程手上的引用还在这个TCP连接不会真正断开。客户端看着连接还在资源白白占着不释放。这是最常见也最隐蔽的bug。我再多说一句一定是在fork之后再关闭不能在fork之前关。fork之前listenfd和connfd都还是父进程在用的关了就全没了。顺序错了整个并发模型直接崩掉。3.3 编译运行与基础验证用telnet和nc做连通性测试这段代码保存成server.c编译命令如下gcc -o server server.c ./server然后另开一个终端用telnet或者nc连上去测试telnet 127.0.0.1 8888 # 或者 nc 127.0.0.1 8888随便敲点字符看是否原样回显。多开几个终端连上不同的连接每个连接应该都能正常工作且互不影响。验证并发时顺便做两件事看进程树ps -ef | grep server。你会发现一个主进程加若干个server子进程每个活跃连接对应一个子进程。看连接状态netstat -anp | grep 8888。你可以看到LISTEN状态的主进程端口ESTABLISHED状态的连接它们分别对应不同的子进程Pid。如果这两步表现正常说明你的多进程并发模型已经从写出来了变成跑起来了。4. 三座大山僵尸进程、惊群和端口复用4.1 子进程的收尸线程SIGCHLD信号与waitpid基本框架跑起来之后你很快就会遇到一个问题子进程处理完连接退出父进程如果不回收它就变成僵尸进程。僵尸进程不占CPU也不占内存但会占着进程表的一个条目进程表满了系统就再也fork不出新进程了。你在ps里看到的僵尸进程就是因为父进程没有调用wait/waitpid去收尸。解决僵尸进程的标准姿势是父进程注册SIGCHLD信号处理函数在信号处理里调用waitpid()回收子进程。void sig_chld(int signo) { pid_t pid; int stat; // WNOHANG: 如果没有子进程退出立刻返回不阻塞 while ((pid waitpid(-1, stat, WNOHANG)) 0) { printf(child %d terminated\n, pid); } }注册信号处理的方式我建议用sigaction而不是signal()因为它语义更可控、更可移植struct sigaction sa; sa.sa_handler sig_chld; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; // 让被信号打断的accept自动重启 if (sigaction(SIGCHLD, sa, NULL) 0) { perror(sigaction); exit(1); }这就有个很有意思的连锁反应了你在accept的时候如果此时恰好有SIGCHLD信号递达accept会被信号中断返回EINTR错误。没有SA_RESTART标志的话你得自己在代码里判断EINTR并继续accept有了SA_RESTART内核会自动重启动accept省去这个判断。代码里就算写了EINTR判断也不矛盾双保险没问题。关于收尸还有几个容易踩的坑记录下来给你避雷waitpid要用循环不能用单次wait。因为多个子进程同时退出时父进程可能只收到一次SIGCHLD信号信号会合并不用循环waitpid的话会漏掉一部分子进程的回收。信号处理函数里不要调用printf这类非异步信号安全的函数。这是为了显示方便才在示例里用线上代码建议只用waitpid日志另想办法。过个两秒可能看不出问题但万一信号风暴来了整个服务就变得不稳定了。4.2 accept惊群问题与多进程下的处理策略在多进程模型里还有一个桌面级经典现象叫惊群。假设你开了多个子进程不是一连接一fork而是启动时就fork了一堆子进程让所有子进程都阻塞在accept同一个listenfd上。这时候如果来一个新连接内核唤醒的往往不止一个子进程而是所有阻塞在accept上的子进程都被唤醒但最终只有一个能成功accept其余的都扑了个空白白被唤醒一次造成不必要的上下文切换。历史上Linux内核在accept层面做过一些优化比如2.6内核以后阻塞在同一个fd上的多个进程在accept时通常只有一个会被真正唤醒相比早期已经有了改进但这并不是完备保证也不意味着你可以高枕无忧。如果你的设计就是多个子进程同时accept为了守住底线可以考虑在accept外面加一把进程互斥锁让同一时刻只有一个进程在accept。这在Nginx等高性能服务里有类似的做法。不过我必须说清楚一连接一fork的主进程accept模型本身不存在惊群问题因为只有一个进程在accept。惊群只会在预派生子进程、子进程并发accept的进程池模型里才需要认真考虑。后面我会展示进程池模型的代码到时候再回到这个问题上。4.3 SO_REUSEADDRTIME_WAIT状态的端口复用在TCP服务端编程里你早晚会碰见一个让人抓狂的错误bind: Address already in use原因很简单你暴力关闭了服务器但还有连接处于TIME_WAIT状态主动关闭方进入的等待状态内核认为这个端口还处于被占用的状态不允许立即bind复用。解决办法就是开头我写的那一行setsockoptint reuse 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, reuse, sizeof(reuse));这个选项的作用是允许端口处于TIME_WAIT状态时也能立即重新绑定。理解一下场景区别服务器主动关闭连接如果服务器的子进程在处理完业务后自己close了连接比如服务端先断开该连接会进入TIME_WAIT状态。这时候如果服务器重启bind就会失败。客户端主动断开TIME_WAIT发生在客户端那边服务器这边通常不影响重启。所以在服务器端写代码时listenfd上无条件加上SO_REUSEADDR是安全的、推荐的做法。但注意这只是让服务器能重启它不会让处于TIME_WAIT的连接凭空消失这是TCP协议的机制不是bug。如果你遇到更诡异的情况明明用了SO_REUSEADDR还是bind失败先检查netstat -anp | grep 端口看看这个端口到底被谁占着八成是另一个进程还活着那就不是TIME_WAIT的问题了。5. 加固与进阶从能跑到好用的三大改动5.1 进程池预派生让连接处理更稳定边accept边fork虽然能跑但在连接突增的瞬间fork这个昂贵的系统调用会成为瓶颈。而且频繁fork/exit会带来进程创建销毁的开销。成熟的优化思路是进程池预派生服务启动时一次性fork出N个子进程每个子进程自己调accept谁抢到连接谁处理。预派生方案的好处非常明显子进程启动成本与连接到达时机解耦连接来时不用现场fork。子进程数量可控不会因为短时间大量连接而疯狂fork导致系统过载。可以利用多核CPU多个子进程并行accept。核心代码结构大概是这样void process_pool(int listenfd, int nproc) { pid_t pid; for (int i 0; i nproc; i) { pid fork(); if (pid 0) { perror(fork); exit(1); } if (pid 0) { // 子进程直接进入accept循环 while (1) { int connfd accept(listenfd, NULL, NULL); if (connfd 0) { if (errno EINTR) continue; perror(accept); exit(1); } handle_client(connfd); close(connfd); } } } // 父进程只负责监控子进程状态 while (1) { pause(); // 等待信号 } }这个方案里子进程们共享同一个listenfd不需要关listenfd因为没有父亲还在accept它。每个子进程accept后连接就是它独占的不存在共享connfd的问题。问题来了刚才说的惊群在这里就变得现实了。一个连接到达时内核确实可能唤醒多个accept子进程。不过实测下来如果子进程个数控制在CPU核数左右比如4到8个惊群开销可以接受如果你需要更极致的性能可以考虑在各子进程accept前加互斥锁但我不建议在入门阶段就去优化这个先把基础模型跑稳理解清楚每个API的作用再去感知性能差异。5.2 超时控制与优雅退出不搞一锤子买卖服务器程序里最容易被忽视的就是**连接只读不写或者连接只在异常时才断**这样的场景。如果客户端建立连接后不发数据、不断开也不关闭你的子进程会一直阻塞在read上连接资源就白白挂着。给read加上超时控制主要有两种方式方式一用setsockopt设置SO_RCVTIMEO和SO_SNDTIMEOstruct timeval tv; tv.tv_sec 30; tv.tv_usec 0; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv)); setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, tv, sizeof(tv));设置之后如果在指定时间内没有数据到达read返回-1errno是EAGAIN或EWOULDBLOCK。你的代码就可以判断出这个连接超时了主动收尾。方式二用alarm信号或者select/poll做超时控制。这种更细腻但代码复杂一些入门阶段用SO_RCVTIMEO就够了。再有就是优雅退出。收到退出信号比如SIGTERM时不要直接exit先释放资源、记录日志、尽量让子进程处理完手头数据再退出。这需要在信号处理里做文章但如果你只想要稳至少要做到父进程被子进程回收干净、退出时关闭listenfd、不产生僵尸进程。5.3 用一个连接处理变体的完整示例HTTP静态文件服务回显服务器演示了连接处理逻辑的框架但实际项目中你往往要针对每一种业务类型写不同的处理函数。我举个非常实用的例子——在子进程里实现一个极简HTTP静态文件服务返回服务器本地的文件内容。void handle_http_client(int connfd) { char req[4096]; int n read(connfd, req, sizeof(req) - 1); if (n 0) return; req[n] \0; // 极简解析只处理GET请求提取URI // 源码出于篇幅省略完整解析思路如下 char method[8], path[1024]; sscanf(req, %7s %1023s, method, path); // 去掉开头的/打开本地文件 if (strcmp(method, GET) 0) { FILE *fp fopen(path 1, rb); if (!fp) { const char *resp HTTP/1.1 404 Not Found\r\n\r\n; write(connfd, resp, strlen(resp)); } else { fseek(fp, 0, SEEK_END); long size ftell(fp); fseek(fp, 0, SEEK_SET); char header[256]; int len snprintf(header, sizeof(header), HTTP/1.1 200 OK\r\nContent-Length: %ld\r\n\r\n, size); write(connfd, header, len); char buf[4096]; size_t r; while ((r fread(buf, 1, sizeof(buf), fp)) 0) { write(connfd, buf, r); } fclose(fp); } } close(connfd); }核心思路就是解析请求路径映射到本地文件用HTTP响应头加上Content-Length返回内容。这个版本缺失了对并发连接数的限制、HTTP头部的完整解析、错误状态码的细化但足以作为子进程业务逻辑多样化的示例。实际写业务型TCP服务器时我的建议是把连接处理函数拆成模块读取请求、解析协议、业务处理、组装响应、日志上报各干各的。子进程的逻辑复杂起来以后这个习惯能帮你省下大量的调试时间。6. 常见问题与排查实录6.1 EADDRINUSE端口明明关了还是报占用遇到这个报错的第一反应应该是查状态别慌着重启。使用netstat -anp | grep 8888 lsof -i :8888看看端口是处于LISTEN还是TIME_WAIT。如果是TIME_WAIT加了SO_REUSEADDR就基本能解决如果是LISTEN状态说明还有进程活着用kill关掉它或者换个端口调试。避坑补充还有一种情况是你在一个进程里同时bind了多个不同socket它们互相冲突。排查时把监听端口错开或者统一规划端口段能在源头上少很多麻烦。6.2 accept返回EINTR被信号打断别直接退出这是多进程服务里极容易遇到的一个情况子进程在处理完连接退出时父进程的accept被SIGCHLD信号打断返回EINTR。不少新手在这一步直接perrorexit然后发现服务器跑一会就莫名其妙死掉简直是经典开局。解决方式就是我在代码里写的if (connfd 0) { if (errno EINTR) continue; perror(accept); exit(1); }另外有的系统上accept还有可能返回EMFILE文件描述符耗尽这个错误不能直接退出而应该短暂等待或者重试因为可能是短时间连接数过多。一个可行的处理是sleep一小段时间再continue。遇到EMFILE最好的做法是配合系统级调优调大文件描述符上限。检查当前上限用ulimit -n如果这个值是1024你的服务器最多同时打开一千个左右的fd包括监听socket、管道、日志文件等这显然不够用。改到65535或者更大ulimit -n 65535注意ulimit只对当前shell和它的子进程有效要持久化得去改limits.conf配置文件。6.3 排查工具清单除了gdb这些命令更源气多进程服务出问题先别急着上调试器用命令看全局往往更快查看进程树ps -ef --forest或pstree -ap | grep server一眼看出父子关系。查看连接状态ss -tanp比netstat信息更全更现代可以看到连接对应的进程号。查看端口和收发包netstat -i查网卡统计。实时跟踪系统调用strace -p pid查看某个进程正在做什么系统调用用在某个子进程卡住了的场景非常管用。查看进程打开的文件描述符ls -l /proc/pid/fd/可以确认某个子进程是否还握着不该有的fd。排查时我习惯用三步走先确认进程状态和连接状态再用strace判断阻塞点最后用lsof看fd泄漏。线上排障大部分问题在这三步之内就能定位。6.4 调试压测心得并发数上不去时先检查这四件事我做并发压测时最常遇到的问题就是连接数稍微一多成功率就掉。排查顺序供参考文件描述符上限ulimit -n。这是99%问题的根源。backlog大小。客户端connect失败但服务端没看到连接很多时候是backlog满了。子进程数量与CPU核数。进程池子进程不是越多越好超过CPU核数反而因为上下文切换降低吞吐。TCP内核参数。tcp_tw_reuse和tcp_fin_timeout等参数按需调整但这不是必须的只有在海量短连接场景才需要碰这些。压测工具方面自己写个简单的多线程压测客户端也可以图个快速专业的推荐wrk或ab但wrk对长连接场景支持的深度一般也有Apache Bench可以做HTTP层的简单压测。最后补一段实操心得多说一句我自己的个人体会多进程模型的好处很多时候不是用压力测试测出来的而是在真实运行一段时间后才慢慢显现的。进程隔离意味着你可以在子进程里放心地跑第三方库、调不稳定模块崩溃了大不了重启那个子进程我见过很多团队花大量精力在单进程里处理各种异常分支反而绕过了最简单的隔离屏障。如果你是从零开始学我建议的路径是把基础框架敲一遍故意制造一些场景去观察——比如不处理SIGCHLD会怎样父子进程不关闭fd又会怎样SO_REUSEADDR不加会怎样。这样主动地踩坑比看十遍教程都有用。把多进程模型真正吃透了再去接触多线程和epoll你会发现网络编程的整个知识体系都串起来了。到时候你回过头来看这份代码可能会觉得它朴素但它的每一个设计决策都是Linux服务器编程领域不会被淘汰的核心智慧。
返回列表