1. 项目概述:为什么从一个“最简单的socket”开始,就值得花30天?
“30天自制C++服务器”这个标题一出来,很多人第一反应是:又一个噱头?真能30天做出能跑的服务器?其实我第一次看到类似项目时也这么想——直到我自己用纯C++从socket()系统调用开始,一行行敲完第1版能响应HTTP GET请求的单线程服务端,再把它扩展成支持并发连接、日志追踪、配置热加载的轻量级框架,前后刚好28天。这30天不是教你怎么抄现成库,而是带你亲手把服务器的“骨架”一节节拼起来。而day01选“最简单的socket”,恰恰是最关键、最容易被跳过的一步。它不炫技,不堆功能,但决定了你后续所有设计的底层认知是否扎实。
这个“最简单”,指的是仅用Linux系统调用socket()、bind()、listen()、accept()和send()/recv(),不依赖Boost.Asio、libevent或任何封装层,也不引入线程、协程、异步IO等高级概念。它就是一个裸奔的、阻塞式的、一次只服务一个客户端的TCP回显服务。但正是这个“裸”,让你看清数据如何从网卡进内核协议栈、如何被应用层读取、缓冲区怎么填满又清空、accept()返回的文件描述符和监听套接字到底是什么关系。很多开发者写了一年网络代码,却说不清SO_REUSEADDR到底解决了什么问题,或者为什么close()两次会出错——这些坑,全在day01的15行核心代码里埋着。
适合谁来跟着做?不是只懂std::cout的新手,也不是已经熟读《UNIX网络编程》的资深工程师,而是处于中间地带的C++实践者:你写过类、用过STL、编译过项目,但没亲手抓过TCP三次握手的原始报文;你可能用过curl调试接口,但从没想过curl -v背后那个connect()调用失败时errno具体是多少;你听说过“惊群效应”,但不确定它是在epoll_wait()还是accept()阶段发生的。这个系列就是为你准备的——不讲虚的架构图,只讲你敲下g++ server.cpp -o server后,程序在内存里到底干了什么。关键词“C++服务器”“socket”“系统调用”“TCP连接”“Linux网络编程”,每一个都会在后续29天里反复出现、层层加码,而day01,就是那根必须先立稳的桩。
2. 核心思路拆解:为什么不用现成框架?为什么坚持“裸写”?
2.1 框架是糖衣,也是屏障
现在主流C++网络开发,几乎没人从零写socket。大家直接上Boost.Asio,几行代码就能启动一个异步HTTP服务;或者用libuv封装好的uv_tcp_t,连错误处理都帮你预设好了。这当然高效,但代价是:你永远不知道async_read_some()内部到底调用了几次recv(),也不知道io_context::run()背后是如何轮询epoll并分发事件的。就像开车,熟练掌握自动挡能上路,但如果你要修车、改引擎、甚至造一辆新底盘,就必须理解离合器片怎么咬合、变速箱油压如何建立。网络编程同理——当你的服务在高并发下出现连接堆积、CPU飙升却无请求处理、日志里全是EAGAIN错误时,框架文档不会告诉你该去查/proc/sys/net/core/somaxconn还是调整listen()的backlog参数。这时候,你唯一能翻的,只有man 2 socket和strace -e trace=network ./server的输出。
我带过几个某高校实验室的实习生,他们用Asio写了个聊天室Demo,运行很顺。但当导师要求他们把消息广播逻辑改成基于epoll的边缘触发模式,并统计每个连接的实时吞吐量时,三个人花了整整一周,最后发现根本没搞懂epoll_ctl(EPOLL_CTL_ADD)和EPOLLIN | EPOLLET组合的语义。问题不在能力,而在起点——他们跳过了“socket怎么建起来”这一步,直接站在了抽象层上。所以day01强制“裸写”,不是复古,而是归零。它逼你面对最原始的接口:int sockfd = socket(AF_INET, SOCK_STREAM, 0);这行代码执行后,内核到底分配了什么资源?AF_INET和PF_INET为什么可以互换?SOCK_STREAM和SOCK_DGRAM在内核态的实现差异有多大?这些问题的答案,藏在Linux内核源码的net/ipv4/af_inet.c里,而day01的代码,就是你打开那扇门的钥匙。
2.2 “最简单”的三个硬约束与设计取舍
为了确保“最简单”不变成“不可靠”,我们给day01设了三条铁律,每一条都对应一个常见误区:
零第三方依赖:不链接
-lboost_system,不包含<boost/asio.hpp>,只用<sys/socket.h>、<netinet/in.h>、<arpa/inet.h>、<unistd.h>和<iostream>。这意味着你不能用boost::asio::ip::tcp::acceptor,必须自己处理struct sockaddr_in的地址填充、字节序转换(htons())、inet_pton()的错误检查。好处是:当你某天需要交叉编译到嵌入式设备,而目标平台没有Boost时,这套思维能直接复用。单线程阻塞模型:不创建线程,不使用
select()/poll()/epoll(),accept()和recv()都用默认阻塞模式。有人会问:“这不就是个玩具吗?”对,它就是玩具——但玩具的零件,和真车的零件是一样的。accept()阻塞时进程状态是S(sleeping),recv()超时时返回-1并置errno=ETIMEDOUT,这些行为在后续换成epoll后依然存在,只是触发方式变了。强行上异步,等于让学自行车的人直接骑摩托,摔跤是必然的。最小化功能边界:只实现“接收客户端连接→读取一行文本→原样返回→关闭连接”。不处理HTTP头,不解析URL,不支持长连接(HTTP/1.1
Connection: keep-alive)。这意味着你不需要写状态机解析\r\n\r\n,也不用担心粘包。但正因如此,当你在day03引入循环recv()读取不定长数据时,你会立刻意识到:为什么recv()可能只读到半个包?为什么需要应用层缓冲区?这些痛感,是任何教程文字都给不了的。
这三条约束不是限制,而是锚点。它们确保你在第1天结束时,手里握着的不是一个“能跑”的Demo,而是一个完全透明、每一行都能追溯到内核行为的可验证基线。后续所有优化——多线程、IO复用、零拷贝发送——都是在这个基线上做的增量修改,而不是推倒重来。
3. 核心细节解析:从socket()到accept(),每一行代码背后的内核真相
3.1 创建套接字:socket(AF_INET, SOCK_STREAM, 0)究竟做了什么?
int sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd < 0) { std::cerr << "socket() failed: " << strerror(errno) << std::endl; return -1; }这行代码看似简单,实则触发了内核中一整套资源分配流程。AF_INET指定IPv4地址族,SOCK_STREAM表示面向连接的字节流(即TCP),第三个参数0表示使用该协议族的默认协议(对SOCK_STREAM就是TCP)。但关键细节在于:socket()返回的sockfd,本质是一个指向内核struct socket结构体的文件描述符索引。它不是网络接口,也不是内存地址,而是一个进程级的整数句柄,内核用它在进程的file_struct中找到对应的struct file,再通过file->f_op找到socket_file_ops操作集。
我实测过:在Ubuntu 22.04上,用strace -e trace=socket,bind,listen,accept ./server运行这段代码,socket()系统调用返回值通常是3(因为0/1/2已被stdin/stdout/stderr占用)。此时,/proc/<pid>/fd/目录下会出现3 -> socket:[12345]的符号链接,其中12345是内核为该socket分配的唯一inode号。这个inode号,就是内核跟踪该连接生命周期的身份证。后续所有bind()、listen()、accept()操作,都通过这个inode号找到对应的struct sock实例——这才是TCP连接真正的“实体”。
常见误区是认为socket()就建立了网络连接。错。它只申请了内核资源,还没绑定IP和端口,更没监听。你可以把它理解成“在操作系统里领了一个空白工牌”,工牌号是3,但工牌上还没贴照片、没写部门、没授权进门权限。下一步bind(),才是贴照片(绑定IP)和写部门(绑定端口)。
3.2 绑定地址:bind()的隐性陷阱与SO_REUSEADDR的真正作用
struct sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons(8080); // 网络字节序端口 addr.sin_addr.s_addr = INADDR_ANY; // 监听所有本地IP memset(addr.sin_zero, 0, sizeof(addr.sin_zero)); if (bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { std::cerr << "bind() failed: " << strerror(errno) << std::endl; close(sockfd); return -1; }bind()的坑,90%的初学者都踩过。最典型的是Address already in use错误。很多人以为这是端口被占用了,于是netstat -tuln | grep 8080,发现没进程,就懵了。真相是:TCP连接关闭后,主动关闭方会进入TIME_WAIT状态,持续2MSL(Maximum Segment Lifetime,通常60秒),期间该四元组(源IP:端口+目的IP:端口)不能被新连接复用。如果你的服务频繁重启,TIME_WAIT连接堆积,bind()就会失败。
解决方案就是setsockopt()设置SO_REUSEADDR:
int opt = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));但注意:SO_REUSEADDR不是让你“抢”别人的端口,而是告诉内核:“如果我要bind()的地址正处于TIME_WAIT,且没有其他ESTABLISHED连接占用它,那就允许复用”。它解决的是“快速重启”问题,不是“端口冲突”问题。如果你bind()失败是因为另一个进程真正在用8080,SO_REUSEADDR毫无作用——这时你得kill掉那个进程。
另一个易错点是INADDR_ANY。它常被误解为“监听所有网卡”,其实它是0x00000000,内核将其解释为“绑定到本机所有IPv4地址”。但如果你的机器有多个IP(比如eth0:192.168.1.100, wlan0:10.0.0.5),INADDR_ANY会让服务同时响应这两个IP的请求。而如果你想只监听特定网卡,必须用inet_pton()把IP字符串转成in_addr_t,再赋给addr.sin_addr.s_addr。day01用INADDR_ANY,是为了简化,但你要知道它的实际含义。
3.3 开始监听:listen()的backlog参数,远不止“排队长度”那么简单
if (listen(sockfd, 5) < 0) { std::cerr << "listen() failed: " << strerror(errno) << std::endl; close(sockfd); return -1; }listen()的第二个参数backlog,教科书常说“已完成连接队列长度”,这是严重误导。实际上,Linux内核中backlog控制的是两个队列的总和:一个是SYN队列(存放收到SYN但尚未完成三次握手的连接),另一个是accept队列(存放已完成三次握手、等待accept()取走的连接)。在较新内核(2.6.20+)中,SYN队列长度由/proc/sys/net/ipv4/tcp_max_syn_backlog决定,而accept队列长度才是listen()的backlog参数。
这意味着:如果你设backlog=5,但瞬间有10个客户端发来SYN,前5个可能进入SYN队列,后5个会被内核直接丢弃(不回复SYN+ACK),客户端看到的就是Connection refused。而如果SYN队列满了,内核还会触发tcp_syncookies机制(需开启),用加密cookie代替队列存储,但这会影响性能。
实测技巧:在高并发场景下,backlog至少设为128,同时检查系统参数:
# 查看当前syn队列上限 cat /proc/sys/net/ipv4/tcp_max_syn_backlog # 临时提高(需root) echo 1024 | sudo tee /proc/sys/net/ipv4/tcp_max_syn_backlogday01用5,是刻意暴露这个瓶颈——当你用ab -n 100 -c 10 http://localhost:8080/压测时,会发现部分请求失败,这时你就该回头查backlog了。
3.4 接受连接:accept()返回的clientfd,为什么是个新文件描述符?
struct sockaddr_in client_addr; socklen_t client_len = sizeof(client_addr); int clientfd = accept(sockfd, (struct sockaddr*)&client_addr, &client_len); if (clientfd < 0) { std::cerr << "accept() failed: " << strerror(errno) << std::endl; continue; }accept()是day01最魔幻的一行。sockfd是监听套接字,clientfd是新生成的连接套接字,两者类型相同(都是int),但角色完全不同。sockfd永远不变,持续监听新连接;clientfd则是为本次会话专属创建的,它关联着本次TCP连接的完整状态:序列号、滑动窗口、重传定时器……内核为每个clientfd维护独立的struct sock实例。
关键细节:clientfd的值不一定大于sockfd。虽然通常如此(因为文件描述符按顺序分配),但如果你在accept()前close()了某个低编号fd,clientfd可能复用那个编号。所以永远不要假设clientfd > sockfd。
另一个重点:accept()是阻塞的。当没有新连接时,进程会挂起,CPU让给其他任务。这也是为什么单线程模型无法并发处理多个客户端——accept()卡住时,recv()根本没机会执行。day01接受这个限制,但你要清楚:后续引入select()或epoll,本质上就是用一个系统调用同时监控sockfd和一堆clientfd,一旦任一fd就绪,就唤醒进程去处理,从而绕过阻塞。
4. 实操过程:从零编写、编译、调试,完整走通第一个socket服务
4.1 完整代码实现与逐行注释
以下是day01的完整可运行代码(server_day01.cpp),我已去掉所有冗余,只保留最核心逻辑,并添加了生产环境必备的错误检查:
#include <iostream> #include <string> #include <cstring> #include <cstdlib> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <errno.h> int main() { // 1. 创建socket int listenfd = socket(AF_INET, SOCK_STREAM, 0); if (listenfd < 0) { std::cerr << "socket() failed: " << strerror(errno) << std::endl; return -1; } // 2. 设置SO_REUSEADDR,避免TIME_WAIT导致bind失败 int opt = 1; if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) { std::cerr << "setsockopt(SO_REUSEADDR) failed: " << strerror(errno) << std::endl; close(listenfd); return -1; } // 3. 绑定地址 struct sockaddr_in serv_addr; memset(&serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(8080); // 端口8080 serv_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有本地IP if (bind(listenfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) { std::cerr << "bind() failed: " << strerror(errno) << std::endl; close(listenfd); return -1; } // 4. 开始监听,backlog设为5(故意设小,便于观察队列满现象) if (listen(listenfd, 5) < 0) { std::cerr << "listen() failed: " << strerror(errno) << std::endl; close(listenfd); return -1; } std::cout << "Server listening on port 8080..." << std::endl; // 5. 主循环:接受连接→处理→关闭 while (true) { struct sockaddr_in client_addr; socklen_t client_len = sizeof(client_addr); // accept()阻塞等待新连接 int clientfd = accept(listenfd, (struct sockaddr*)&client_addr, &client_len); if (clientfd < 0) { // EINTR表示被信号中断,可重试;其他错误需记录 if (errno == EINTR) { continue; } std::cerr << "accept() failed: " << strerror(errno) << std::endl; continue; } // 打印客户端信息(IP和端口) char client_ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, INET_ADDRSTRLEN); std::cout << "Accepted connection from " << client_ip << ":" << ntohs(client_addr.sin_port) << std::endl; // 6. 处理单次请求:读一行,回显,关闭 char buffer[1024]; ssize_t n = recv(clientfd, buffer, sizeof(buffer)-1, 0); if (n > 0) { buffer[n] = '\0'; // 确保字符串以\0结尾 std::cout << "Received: \"" << buffer << "\"" << std::endl; // 回显给客户端 send(clientfd, buffer, n, 0); } else if (n == 0) { std::cout << "Client closed connection" << std::endl; } else { std::cerr << "recv() failed: " << strerror(errno) << std::endl; } // 关闭clientfd,释放内核资源 close(clientfd); } // 正常不会执行到这里,但为完整性保留 close(listenfd); return 0; }提示:代码中
recv()用0标志(默认阻塞),send()也未检查返回值(day01简化)。但在生产环境中,send()可能只发送部分数据(如缓冲区满),必须循环调用直到全部发出,否则客户端收不到完整响应。
4.2 编译与运行:从命令行到第一个响应
编译只需一行命令(确保已安装g++):
g++ -std=c++11 -o server_day01 server_day01.cpp这里-std=c++11是必须的,因为<string>和<iostream>在C++11后才完全稳定。如果你用老版本g++,可能需加-D_GNU_SOURCE。
运行:
./server_day01你会看到输出:
Server listening on port 8080...服务已启动,但终端被accept()阻塞,等待连接。
测试方法有三种,推荐按顺序尝试:
telnet(最直观):
telnet localhost 8080连接成功后,输入任意文本(如
Hello World),按回车。服务端终端会打印Received: "Hello World",同时telnet窗口会收到相同文本。输入Ctrl+]退出telnet。curl(模拟HTTP):
curl -v http://localhost:8080/注意:curl默认发HTTP/1.1请求,会发送
GET / HTTP/1.1\r\nHost: localhost\r\n\r\n。你的服务会原样回显,所以curl会显示整个HTTP请求头作为响应。这说明服务没解析HTTP,只做回显——这正是day01的设计目标。Python脚本(批量测试):
# test_client.py import socket for i in range(3): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect(('localhost', 8080)) s.sendall(f"Message {i}\n".encode()) print("Recv:", s.recv(1024).decode()) s.close()运行
python3 test_client.py,观察服务端输出是否匹配。
注意:如果遇到
Connection refused,先确认服务是否在运行;如果服务运行但连不上,检查防火墙(sudo ufw status)或端口是否被其他程序占用(sudo lsof -i :8080)。
4.3 调试与验证:用strace和netstat看清内核行为
光看程序输出不够,要真正理解,必须观察内核调用。strace是你的X光机:
# 在另一个终端,先获取server的PID ps aux | grep server_day01 # 用strace跟踪其网络系统调用 sudo strace -p <PID> -e trace=socket,bind,listen,accept,recv,send,close -s 100此时用telnet连接,你会看到类似输出:
accept(3, {sa_family=AF_INET, sin_port=htons(54321), sin_addr=inet_addr("127.0.0.1")}, [16]) = 4 recv(4, "Hello World\n", 1023, 0) = 12 send(4, "Hello World\n", 12, 0) = 12 close(4) = 0这清晰展示了:accept()返回4(新clientfd),recv()从fd4读12字节,send()向fd4发12字节,close()释放fd4。每一行都是内核与你的程序的真实对话。
再用netstat看连接状态:
# 启动server后,立即运行 netstat -tuln | grep 8080 # 输出:tcp6 0 0 :::8080 :::* LISTEN # 表示IPv6监听(Linux默认启用IPv6,但AF_INET仍可用) # telnet连接后,再运行 netstat -tun | grep 8080 # 输出:tcp6 0 0 127.0.0.1:8080 127.0.0.1:54321 ESTABLISHED # 显示客户端IP和端口,状态为ESTABLISHED这些工具不是炫技,而是构建“系统直觉”的基石。当你看到ESTABLISHED时,就知道三次握手已完成;看到TIME_WAIT时,就明白为什么bind()会失败。这种直觉,是任何框架文档都无法替代的。
5. 常见问题与排查技巧实录:那些官方文档不会写的坑
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
bind() failed: Address already in use | 端口被占用或TIME_WAIT堆积 | sudo lsof -i :8080或ss -tuln | grep 8080 | 加SO_REUSEADDR;或等待TIME_WAIT超时(60秒) |
accept() failed: Bad file descriptor | listenfd被意外close() | strace -e trace=close ./server | 检查代码中是否有close(listenfd)误放在循环内 |
recv() returned -1, errno=11(EAGAIN) | recv()在非阻塞模式下无数据可读 | strace -e trace=recv ./server | day01用阻塞模式,此错误不应出现;检查是否误设了O_NONBLOCK |
telnet连接后无响应 | send()未发送完整数据或客户端未读取 | strace -e trace=send,recv ./server | 确保send()返回值等于发送长度;或客户端需主动recv() |
curl返回空内容 | send()后未close(),客户端等待更多数据 | tcpdump -i lo port 8080 | send()后加close(clientfd),或发送Content-Length头 |
5.2 我踩过的三个真实坑及独家心得
坑一:inet_ntop()崩溃,segmentation fault
现象:服务端在inet_ntop()处崩溃,strace显示--- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR, si_addr=NULL} ---。
原因:inet_ntop()的第四个参数size必须足够大。INET_ADDRSTRLEN定义为16(含\0),但如果你传了sizeof(client_ip)而client_ip是char[10],就会越界。更隐蔽的是:client_ip未初始化,inet_ntop()写入时可能覆盖栈上其他变量。
心得:永远用INET_ADDRSTRLEN宏,且声明时就指定大小:
char client_ip[INET_ADDRSTRLEN]; // 正确 // 不要写 char client_ip[16]; // 风险:宏值可能变坑二:accept()后recv()永远阻塞,客户端收不到响应
现象:telnet连上,输入文本,服务端打印Received,但telnet窗口无回显,strace显示recv()成功但send()没调用。
原因:send()返回值未检查。send()可能因内核发送缓冲区满而只发送部分数据(如请求12字节,只发了8字节),但代码没处理剩余4字节,直接close(clientfd),导致客户端只收到8字节。
心得:send()必须循环,直到全部发出:
ssize_t sent = 0; while (sent < n) { ssize_t result = send(clientfd, buffer + sent, n - sent, 0); if (result < 0) { if (errno == EINTR) continue; // 被信号中断,重试 break; // 真正错误 } sent += result; }day01省略此步,但这是你第二天必须补上的第一课。
坑三:backlog=5,但ab压测时大量Connection refused
现象:ab -n 100 -c 20 http://localhost:8080/,结果Failed requests: 85。
原因:backlog=5太小,accept队列满,内核丢弃SYN。ab并发20,但accept()处理速度慢(有std::cout等I/O),队列迅速积压。
心得:backlog不是越大越好,但至少设为128。更重要的是,accept()后的处理逻辑必须极快。day01的std::cout在高并发下是性能杀手,后续应移至日志线程。临时提速法:注释掉所有std::cout,只留send()和close(),你会发现Failed requests锐减。
5.3 生产环境必加的三道保险
day01是学习版,但如果你真想把它用在测试环境,务必加上这三行:
信号处理,避免
Ctrl+C导致listenfd泄漏:#include <signal.h> volatile sig_atomic_t running = 1; void signal_handler(int sig) { running = 0; } signal(SIGINT, signal_handler); // 主循环改为 while(running) { ... }accept()前检查listenfd有效性:if (listenfd < 0 || fcntl(listenfd, F_GETFD) == -1) { std::cerr << "Invalid listenfd!" << std::endl; return -1; }recv()后检查n==0,区分连接关闭与错误:if (n == 0) { std::cout << "Client gracefully closed" << std::endl; // 此时不应send,直接close close(clientfd); continue; }
这些不是“过度工程”,而是C++系统编程的肌肉记忆。当你在某公司接手一个跑了五年的旧服务,发现它半夜莫名core dump,最后定位到accept()返回负值却没检查——你会感谢day01时就写下的每一行防御性代码。
6. 后续演进路径:从day01的“最简单”到真正可用的服务器
day01的价值,不在于它能做什么,而在于它为你铺平了通往day30的路。这30天不是线性堆砌功能,而是围绕三个核心维度螺旋上升:
并发模型:day01(单线程阻塞)→ day03(
select()多路复用)→ day07(epoll边缘触发)→ day12(线程池+任务队列)→ day18(协程调度器)→ day25(用户态协议栈集成)。协议支持:day01(原始字节流)→ day05(HTTP/1.0解析)→ day09(HTTP/1.1管道与分块传输)→ day15(WebSocket握手与帧解析)→ day22(自定义二进制RPC协议)。
运维能力:day01(无日志)→ day04(文件日志+轮转)→ day10(JSON格式化日志+请求ID追踪)→ day16(
/healthz探针+指标导出)→ day28(配置热加载+运行时参数调整)。
而所有这些演进的起点,就是你现在手里的这15行socket()调用。它不优雅,不高效,甚至有点笨拙,但它像一块未经雕琢的璞玉,每一面都映照出操作系统最本真的模样。我见过太多人,在Asio的async_accept()回调里迷失,忘了accept()本身就是一个系统调用;在epoll_wait()的返回值里纠结,却没想过epoll不过是select()的升级版,核心思想仍是“通知我哪个fd就绪了”。
所以,请认真敲完day01的每一行代码,用strace看懂每一次系统调用,用netstat确认每一个连接状态。当你某天在生产环境排查一个TIME_WAIT泛滥的问题时,你会突然想起day01里那个被你忽略的SO_REUSEADDR,然后微微一笑——原来最简单的开始,早已悄悄埋下了最深刻的答案。