ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Linux套接字编程基础:IP端口、字节序与TCP/UDP

Linux套接字编程基础:IP端口、字节序与TCP/UDP 写 Linux 网络编程的代码最先卡住人的往往不是多路复用也不是高性能模型而是 bind 返回失败、连接超时、对端收到的数据是一串乱码这三件小事。这三个现象背后分别对应着端口、地址和字节序——也就是Linux 网络编程里**套接字socket**这套机制最基础的那层预备知识。很多人一上手就抄一个 TCP 回显服务器的模板代码跑通了就以为会了等真正要改端口、要跨机器、要自己定协议的时候立刻原形毕露。这篇内容想做的事很明确把套接字这层抽象下面压着的基本概念一个个翻出来讲清楚包括 IP 与端口怎么组合成一个可寻址的通信端点、字节序为什么必须转换、TCP 和 UDP 的取舍到底在取舍什么、sockaddr这一族结构体为什么要绕这么一圈。它适合刚接触 Linux 系统编程的同学也适合写了几年业务代码、但一直没系统梳理过网络基础的人。文中会给出可直接编译运行的示例、参数计算过程和我在实际调试中踩过的坑你可以当成一份动手前的检查清单来用。1. 先把地图画清楚套接字在整个网络通信里站在哪一层1.1 从一次文件传输说起网络编程到底在折腾什么假设你在本机写了一个程序要把一个文件的内容发给另一台机器上的程序。最朴素的想法是我打开网卡、把字节一个个推出去。但这件事在用户态根本做不了网卡是硬件访问它要走驱动而驱动属于内核。所以真实的路径是你的进程调用一组系统调用把我要发这些字节给某个目标这个意图告诉内核内核里的协议栈负责封装、校验、重传、路由最后交给网卡。这组系统调用对外暴露的接口就是套接字。理解这一点非常重要因为它决定了套接字 API 的性格它不是一个函数调用就完成通信而是你去内核里登记一个通信端点然后对这个端点做读写。所以你会看到socket()创建一个东西、bind()给它挂地址、listen()让它能接客、accept()从它身上取新连接这一串动作本质是在内核里维护一份数据结构而不是在用户态算什么东西。判断一个概念属不属于预备知识我的标准很简单如果不搞清楚它你写出来的代码能跑但一改环境就崩。IP、端口、字节序、协议类型、地址结构体全部符合这个标准。1.2 套接字的本质一层被操作系统抽象出来的文件在 Linux 上socket()的返回值是一个int和open()返回的东西是同一类——文件描述符。这不是巧合而是有意为之的设计把网络通信抽象成文件读写用户态就能复用同一套 I/O 模型阻塞、非阻塞、select/poll/epoll、O_NONBLOCK等。于是你可以在 socket fd 上做read()/write()也可以用send()/recv()还能把它塞进epoll的监听集合里。但这层像文件的抽象并不完全几个地方会露馅。文件有偏移量可以lseek到任意位置读socket 不行它是流读过就没了文件有明确长度stat一下就知道多大socket 不知道对端还会发多少只能一直读到对端关闭或按协议自己定边界文件关闭通常没有副作用socket 的close()会触发四次挥手关闭时机不对会留下TIME_WAIT。这几个差异点恰恰是后面所有坑的源头。我个人的建议是初学时把 socket 当文件用完全没问题能快速跑通但一旦开始调性能或者排查异常就必须切换到这是一个有状态的双向字节通道的心智模型。1.3 预备知识清单为什么这些概念绕不开把要讲的东西列个清单后面的章节就按这个骨架展开通信端点怎么唯一标识IP 端口、数据在内存里怎么排字节序、通道提供什么保证TCP / UDP、内核用什么结构描述端点sockaddr家族、以及这些 API 之间靠什么约定串起来返回值、错误码、阻塞语义。这份清单不是凭空列的。你回头看那三个典型故障bind失败八成是端口被占或者地址填错落在清单第一项连接超时可能是 IP 不可达、防火墙拦了、也可能是监听端bind在127.0.0.1上只收了本机连接还是落在第一项数据乱码几乎必然是字节序或者结构体对齐落在第二项。换句话说套接字编程里绝大多数看起来诡异的问题都能在这份清单里找到归属。把清单吃透调试就从猜变成查。2. IP 与端口给通信双方一个能寄到的地址2.1 IP 地址定位到哪台机器IP 地址解决的是哪台主机的问题。IPv4 是 32 位写成点分十进制是为了人看比如192.168.1.10本质上就是四个字节。理解这一点很关键因为代码里sin_addr.s_addr存的就是这 32 位整数不是字符串。你要么用inet_pton()把字符串转成整数要么用htonl()把主机序的整数转成网络序绝不能直接把字符串memcpy进去。几个必须分清的特殊地址0.0.0.0INADDR_ANY作为bind的参数时表示绑定本机所有网卡的所有 IP。服务端几乎都用它因为一台机器可能有回环、有线、无线多个地址写死某一个会导致换网就收不到连接。127.0.0.1回环地址只在本机内部有效数据不出网卡。作为bind参数时只有本机进程能连上作为connect目标时只能连本机的服务。255.255.255.255受限广播地址只有开启SO_BROADCAST才能发。私有地址段10/8、172.16/12、192.168/16内网使用跨网段需要转换。这里有个经典误解很多人以为bind到0.0.0.0就等于对外开放。实际上它只决定监听哪个地址能不能被外部连上还取决于路由和链路上的访问控制。反过来如果你在开发机上把服务bind到127.0.0.1然后用另一台机器去连会一直超时——这不是代码 bug是本就没打算让你连。2.2 端口号定位到哪个进程IP 只到机器机器上跑着几十上百个进程数据到了该交给谁靠端口。端口是 16 位无符号整数范围 0 到 65535和 IP 组合成IP:Port这个二元组才能唯一标识一个通信端点。不过严格来说标识一个 TCP 连接的完整四元组是源IP : 源端口 : 目的IP : 目的端口。这也是为什么同一台服务器可以同时接受成千上万个连接目的 IP 和目的端口相同但客户端的源 IP 和源端口不同四元组依然唯一。理解这个四元组你就明白了为什么服务端只监听一个端口却能服务大量客户端这件事在逻辑上是成立的也就不容易被一个端口只能一个连接这种错误直觉误导。端口在 Linux 上分三段用途和权限完全不同范围名称典型用途权限要求0 - 1023知名端口HTTP(80)、SSH(22)、DNS(53)传统上需要特权Linux 可通过net.ipv4.ip_unprivileged_port_start放宽1024 - 49151注册端口各类中间件、数据库默认端口普通用户可用49152 - 65535动态/临时端口客户端connect时内核自动分配普通用户可用写代码时有一个细节容易被忽略bind的端口传 0表示让内核帮我挑一个空闲端口内核挑完后可以用getsockname()读出来。这个技巧在写测试用例、写需要动态端口的服务时特别有用能彻底避免端口冲突导致测试随机失败这种情况。2.3 IP 与端口的组合方式以及结构体里为什么要留一个sin_zero在代码层面IPv4 的地址信息装在struct sockaddr_in里核心三个成员sin_family地址族填AF_INET、sin_port16 位端口网络字节序、sin_addr32 位 IP网络字节序。这三个字段的填法几乎决定了bind和connect能不能成功。struct sockaddr_in里还有个sin_zero[8]很多人不知道它是干嘛的。原因是历史上的struct sockaddr总长 16 字节而sockaddr_in的实质内容只占 8 字节剩 8 字节用它填充对齐保证两者大小一致这样bind(sockfd, (struct sockaddr *)addr, sizeof(addr))这种强制转换才安全。这个字段必须清零标准做法是定义后立刻memset(addr, 0, sizeof(addr))否则残留的栈上垃圾数据在某些实现上会引起地址比较异常。我见过有人省略 memset 之后在bind时偶发EINVAL排查了很久最后就是这 8 个字节没清干净。2.4 端口相关的三个高频坑端口被占用EADDRINUSE。最常见的原因是上一个进程没退干净或者进程重启太快、旧连接还处于TIME_WAIT。TIME_WAIT是主动关闭方才会进入的状态持续 2MSLLinux 上约 60 秒目的是保证最后一个 ACK 能被对端收到、并让旧连接的残留报文在网络中消散。服务端频繁重启时几乎必然撞上解决办法是给监听 socket 设置SO_REUSEADDRint on 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on));注意SO_REUSEADDR解决的是地址处于 TIME_WAIT 时仍可绑定不是两个进程同时绑同一个端口。后者在 Linux 上需要SO_REUSEPORT语义完全不同不要混用。绑定地址写死。开发时图省事写inet_addr(127.0.0.1)上线后容器内其他服务连不上。这类问题在容器化环境里尤其常见因为容器的回环和其他容器的网络命名空间是隔离的。用inet_addr()判断错误。这个函数转换失败时返回INADDR_NONE而INADDR_NONE的值恰好是255.255.255.255也就是说你无法区分输入非法和输入就是广播地址。新代码一律用inet_pton()它的返回值语义清晰1 成功、0 格式不对、-1 地址族不支持。3. 字节序跨机器传数据绕不过去的第一个坑3.1 大端与小端到底差在哪一个 32 位整数0x01020304在内存里占四个字节。如果低地址存01、高地址存04叫大端Big-Endian如果低地址存04、高地址存01叫小端Little-Endian。x86 和大部分 ARM 是小端部分嵌入式平台和网络协议规定的是大端。这件事在单机程序里毫无存在感因为读取和写入用的是同一套规则自己跟自己永远对得上。但一旦跨机器发送方按小端把0x01020304铺到内存里发出去接收方按大端解读得到的就是0x04030201——数字完全变了。这也是收到的数据是乱码最常见的成因之一而且它很阴险数据长度对得上、校验也能过只是数值错了排查时容易往业务逻辑上想。可以这样验证自己机器的字节序#include stdio.h #include stdint.h #include arpa/inet.h int main(void) { uint32_t host 0x01020304; uint32_t net htonl(host); unsigned char *p; p (unsigned char *)host; printf(host: %02x %02x %02x %02x\n, p[0], p[1], p[2], p[3]); p (unsigned char *)net; printf(net : %02x %02x %02x %02x\n, p[0], p[1], p[2], p[3]); return 0; }在小端机上输出是host: 04 03 02 01和net: 01 02 03 04两个结果不一样说明转换确实发生了在大端机上两者相同。这段代码我建议每个初学网络编程的人都亲手跑一遍比看十遍文字描述管用。3.2 为什么网络字节序规定为大端协议在设计时需要一个统一约定否则每个实现都要在运行时协商成本太高。选大端有历史和工程上的原因早期参与制定规范的主流机器多为大端架构而且大端在抓包和人工阅读十六进制时更符合直觉——高位在前和书写顺序一致。这个选择一旦定下来就成了硬约定后来无论本机是什么字节序网络上传输的数值一律按大端解释。落实到代码就是四个函数htonshost to network short16 位、htonl32 位、ntohs、ntohl。规律很好记端口是 16 位用sIP 和大多数整型字段是 32 位用l。这些函数在同类字节序的机器上会被编译成空操作所以不用担心性能。3.3 转换边界在哪里只转数值不转字节流这是最容易被搞混的一点。字节序转换只对多字节数值类型有意义对字节数组没有意义。你发一个字符串hello它就是五个字节逐字节发送接收方逐字节接收顺序天然一致不需要任何转换。你发一个uint32_t长度字段就必须转换。由此推出一条实操原则自定义协议时把所有多字节字段明确用固定宽度类型uint16_t、uint32_t、uint64_t声明并在序列化时逐个转换。不要图省事把整个结构体memcpy进缓冲区发出去原因有两个一是结构体里可能有字节序不同的字段二是编译器会在成员之间插入对齐填充填充内容是不确定的接收方按同样的结构体解析可能对得上换编译器、换优化等级、换平台就可能错位。htons/htonl只覆盖 16 位和 32 位64 位没有标准函数。跨平台项目里常见的做法是自己写一对#include stdint.h static uint64_t hton64(uint64_t v) { #if __BYTE_ORDER__ __ORDER_LITTLE_ENDIAN__ return ((uint64_t)htonl((uint32_t)(v 0xFFFFFFFF)) 32) | htonl((uint32_t)(v 32)); #else return v; #endif }或者干脆在协议设计阶段避开 64 位字段用两个 32 位拼省掉这一层。我在实际项目里更倾向后者协议越简单跨语言实现时出错概率越低。3.4 浮点数和长时间戳的额外注意点浮点数不要直接发二进制。IEEE 754 的位模式虽然也是定长的但不同平台的实现细节、NaN 表示、扩展精度都可能不同直接传字节在不同编译器之间不一定可靠。稳妥做法是转成定点整数比如金额用分为单位存int64_t或者转成十进制字符串发送牺牲一点带宽换确定性。时间戳同理。推荐统一用自纪元起的秒数或毫秒数作为int64_t转换后传输避免传struct timeval、struct timespec这类结构体——它们的成员宽度和布局在各平台上并不一致。这一点在写跨平台通信协议时值得单独列一条规范比事后调试省事得多。4. 协议的选择TCP 和 UDP 的取舍不是在选技术是在选责任4.1 TCP面向连接、可靠、字节流SOCK_STREAM对应的就是 TCP。它提供的保证可以概括成四条连接是有状态的三次握手建立、四次挥手关闭、数据不丢不重不乱序、有流量控制和拥塞控制、传输的是字节流而非消息。字节流这三个字是理解 TCP 的关键也是后面所有粘包讨论的根源。TCP 只保证你写进去的字节会按顺序到达不保证边界。你在发送端write了两次每次 100 字节接收端可能一次read到 200 字节也可能分三次读到 60、60、80。这不是 bug是设计如此——TCP 把自己定位成一根可靠的水管水管不负责告诉你这一杯水到哪里结束。所以任何基于 TCP 的应用层协议都必须自己定义边界常见三种做法方案做法适用场景缺点固定长度每条消息定长消息长度恒定的场景不灵活浪费带宽长度前缀先发 4 字节长度再发内容通用二进制协议需要处理长度字段本身的字节序分隔符用\r\n或\0结束文本协议、命令行内容里出现分隔符需要转义长度前缀是我最常用的方案简单、高效、无歧义。实现上有两个细节长度字段必须用htonl转换读取时必须循环读满因为read可能只返回部分数据。static int read_full(int fd, void *buf, size_t n) { size_t got 0; while (got n) { ssize_t r read(fd, (char *)buf got, n - got); if (r 0) return 0; /* 对端关闭 */ if (r 0) { if (errno EINTR) continue; /* 被信号打断重试 */ return -1; } got (size_t)r; } return 1; }这段代码短但包含了三个新手经常漏掉的点处理短读、处理EINTR、区分读满和对端关闭两种情况。漏掉任何一个在高负载或者有信号的环境下就会出现随机失败而且极难复现。4.2 UDP无连接、不可靠、数据报SOCK_DGRAM对应 UDP。它没有连接、不保证送达、不保证顺序、可能重复但保留了消息边界你发一个数据报接收方要么收到完整的一个要么收不到不会出现半截。这一点和 TCP 正好相反。UDP 的优势在于开销小、延迟低、没有队头阻塞。一个数据报丢了只影响它自己不会卡住后面的数据TCP 则因为要保证顺序前面一个包丢了后面全得等重传。所以在实时性优先、能容忍少量丢包的场景里UDP 是更合理的选择。另外 UDP 天然支持一对多广播、组播TCP 做不到。需要注意的是UDP 的简单只体现在协议层应用层的复杂度一点没少要自己做超时重传、自己做序号去重、自己做拥塞控制否则一旦网络拥塞就是灾难。很多人在 UDP 上重新实现了一遍 TCP 的可靠性机制结果比直接用 TCP 还差。这不是说不能做而是要有清醒判断——如果你的需求是可靠传输先问问自己为什么不用 TCP。4.3 选型不是选技术是选你要承担哪部分责任我判断的方式是问三个问题丢一个包会怎样如果业务上完全不能接受比如转账指令用 TCP让协议栈帮你扛。延迟敏感吗如果晚到不如不到比如实时语音、游戏位置同步UDP 更合适应用层做有限的补偿。是不是一对多广播、组播只能用 UDP。其余情况默认选 TCP把精力放在业务上。选 UDP 通常是经过权衡后的主动决策而不是因为它快。4.4 粘包这个词其实起错了名字社区里常说TCP 粘包严格讲这个说法不准确TCP 没有粘它本来就是流从未承诺过边界。真正的问题是应用层没有定义消息边界。所以解决粘包的正确姿势不是去调什么 socket 参数而是在协议设计阶段就把边界规则写清楚然后在接收端按规则切分。理解了这一点你就不会再去找关闭粘包的选项——那东西不存在。4.5 一个常被忽略的细节send返回值和发完了不是一回事send()返回的是成功拷贝进内核发送缓冲区的字节数不等于对端已经收到。缓冲区满了它会阻塞阻塞模式下或者返回EAGAIN非阻塞模式下。所以正确写法同样是循环直到全部写完static int write_full(int fd, const void *buf, size_t n) { size_t sent 0; while (sent n) { ssize_t w send(fd, (const char *)buf sent, n - sent, 0); if (w 0) { if (errno EINTR) continue; if (errno EAGAIN || errno EWOULDBLOCK) { /* 非阻塞模式下应交给事件循环处理此处仅示意 */ continue; } return -1; } sent (size_t)w; } return 0; }在非阻塞模式下EAGAIN的处理不能靠死循环必须回到事件循环里等可写事件。这段逻辑在写高性能服务时是绕不开的但在预备知识阶段先建立send 不代表送达这个认知就够了。5. 地址结构体与 socket API 的整体轮廓5.1sockaddr家族的三个成员很多人第一次看 socket 代码最困惑的就是为什么要在一堆结构体之间来回强转struct sockaddr_in addr; bind(fd, (struct sockaddr *)addr, sizeof(addr));原因是设计上做了一层通用壳。struct sockaddr是抽象的地址表示只有一个 16 位的地址族字段和一段通用的地址数据struct sockaddr_in是 IPv4 的具体表示struct sockaddr_in6是 IPv6 的struct sockaddr_un是本地 Unix 域的。所有 API 的参数统一收struct sockaddr *这样一套接口能同时支持多种地址族代价是调用方必须自己强转并把真实大小通过第三个参数传进去。这个设计的用意是扩展性代价是类型安全为零——强转错了编译器不会报错运行时bind直接返回EINVAL。所以我写这类代码时有个习惯结构体变量名统一加后缀addr4、addr6强转的地方一眼能看出对应关系减少误用。三层结构之间还有一个细节sin_addr的类型是struct in_addr里面只有一个s_addr成员。多套一层纯粹是历史原因实际用时直接操作sin_addr.s_addr就行。IPv6 那边sin6_addr是 16 字节的联合体转换要用inet_pton(AF_INET6, ...)inet_ntop也需要传AF_INET6用错了会静默失败。5.2 一份能跑起来的最小骨架把前面所有概念串起来一个最小的服务器骨架长这样#include stdio.h #include string.h #include unistd.h #include errno.h #include arpa/inet.h #include netinet/in.h #include sys/socket.h int main(void) { int lfd socket(AF_INET, SOCK_STREAM, 0); if (lfd 0) { perror(socket); return 1; } int on 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(8080); addr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(lfd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); return 1; } if (listen(lfd, 128) 0) { perror(listen); return 1; } for (;;) { struct sockaddr_in peer; socklen_t len sizeof(peer); int cfd accept(lfd, (struct sockaddr *)peer, len); if (cfd 0) { if (errno EINTR) continue; perror(accept); break; } char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, peer.sin_addr, ip, sizeof(ip)); printf(client %s:%u\n, ip, ntohs(peer.sin_port)); char buf[1024]; ssize_t n read(cfd, buf, sizeof(buf)); if (n 0) write(cfd, buf, (size_t)n); close(cfd); } close(lfd); return 0; }三十来行但每一行都能对应上前面的概念socket的第一个参数是地址族第二个是套接字类型决定 TCP 还是 UDPSO_REUSEADDR解决TIME_WAITmemset清sin_zerohtons/htonl处理字节序INADDR_ANY监听所有地址listen的第二个参数是等待队列长度accept返回的是新的fd和监听 fd 是两个东西peer里的地址是网络字节序打印前必须ntohs。编译命令记一下gcc -Wall -Wextra -o srv srv.c然后另开一个终端用nc 127.0.0.1 8080连上去试试。这段代码只处理一个连接就关闭不是完整的服务器但足够用来验证环境、验证端口、验证字节序转换是否正确。5.3listen的 backlog 到底影响什么listen(fd, 128)里的 128 不是最大连接数而是未完成连接队列 已完成连接队列的上限参考值。未完成队列放的是收到 SYN、还没完成三次握手的连接已完成队列放的是握手完成、等待accept取走的连接。如果服务端accept太慢已完成队列满了后续新连接的行为取决于tcp_abort_on_overflow的配置默认是静默丢弃客户端表现为连接超时或重试。实际取值不用太纠结Linux 上还会被net.core.somaxconn限制最终生效的是两者较小值。我一般写 128 或 512配合及时accept就够了。真正的瓶颈几乎从来不是这个数字而是accept之后每个连接的处理逻辑是不是阻塞的。5.4read/write与send/recvclose与shutdownsocketfd 可以用read/write也可以用send/recv。前者是通用文件接口后者多了个flags参数能指定MSG_NOSIGNAL避免对端关闭时触发 SIGPIPE 直接杀掉进程、MSG_PEEK窥探不取走、MSG_WAITALL尽量读满。生产代码里我强烈建议用send并带上MSG_NOSIGNAL或者干脆在启动时signal(SIGPIPE, SIG_IGN)否则对端异常断开就可能导致整个服务进程退出这类事故非常典型。close和shutdown的区别也值得单独说。close减少引用计数减到 0 才真正关闭而且它是全双工的——读写一起断。shutdown(fd, SHUT_WR)只关闭写方向对端会收到 EOF但仍然可以继续发数据过来这在我发完了等你回复的场景里是必需的。用close替代shutdown会导致你再也读不到对端的回复很多人调试时遇到发完就收不到响应原因就在这里。5.5 阻塞与非阻塞预备阶段至少要建立的直觉默认创建的 socket 是阻塞的accept没连接就卡住、read没数据就卡住、write缓冲区满就卡住。单连接场景没问题一旦要同时处理多个连接就必须改。两条路一是每连接一个线程简单但线程开销大、连接数上千就撑不住二是非阻塞 事件循环epoll一个线程管成千上万个连接。预备知识阶段不需要把epoll吃透但必须理解阻塞语义是怎么来的以及EAGAIN意味着什么。因为不管你后面用哪种模型这两个概念都会反复出现。我的建议是先写一个阻塞版本跑通逻辑再改成非阻塞观察错误码的变化这个对比过程比直接抄epoll模板有收获得多。6. 常见问题与排查技巧实录6.1 一份能直接对着查的速查表现象最可能原因排查动作bind返回EADDRINUSE端口被占或处于TIME_WAITss -lntp看占用加SO_REUSEADDRbind返回EINVAL结构体没清零 / 地址族填错 / 长度传错检查memset、sin_family、第三个参数connect一直超时目标不可达、被防火墙拦、服务bind在回环ping、telnet ip port、检查bind地址connect返回ECONNREFUSED目标端口没有监听确认服务是否启动、端口是否写错收到的数值不对字节序没转检查htonl/htons/ntohl/ntohs收到的结构体错位直接memcpy结构体含对齐填充改为逐字段序列化大流量下偶发读不完整没处理短读包一层read_full循环进程莫名退出对端关闭触发SIGPIPE用MSG_NOSIGNAL或忽略SIGPIPE端口传 0 后不知道实际端口没调getsockname绑定后用getsockname读回重启服务立刻报端口占用TIME_WAITSO_REUSEADDR或等待 2MSL这张表覆盖了我实际调试中八成以上的情况遇到问题先对一遍能省掉大量瞎猜的时间。6.2 几个只有踩过才知道的细节用ss而不是netstat。ss -lntp看监听、ss -antp看所有 TCP 连接、ss -s看汇总统计比netstat快得多字段也更清楚。排查连接状态分布时ss输出的State列能直接告诉你有多少连接卡在SYN-SENT、多少在TIME_WAIT。strace是定位 socket 调用的利器。strace -f -e tracenetwork ./srv能把所有网络相关的系统调用和返回值打出来bind到底传了什么地址、accept返回了哪个 fd、read返回了几个字节一目了然。我第一次搞不清为什么bind失败就是靠它看到sin_port传的是主机序直接定位。地址打印一定要在转换之后。printf(%s, inet_ntoa(addr.sin_addr))这种写法在单线程里能用但inet_ntoa返回的是静态缓冲区多线程下会互相覆盖。老老实实用inet_ntop加自己的char buf[INET_ADDRSTRLEN]虽然多写一行但省掉的是那种日志里 IP 随机变化的诡异问题。测试用固定端口迟早出事。CI 环境里并发跑多个测试用例固定端口必然冲突。全部改成绑定端口 0 再getsockname拿实际端口这个改动很小收益很大。超时一定要设。connect默认超时可能长达一分钟以上recv默认永不超时。生产代码里SO_RCVTIMEO、SO_SNDTIMEO或者非阻塞 事件循环的超时机制至少要有一种。我见过因为缺超时导致工作线程被一个僵死连接永久占住最后整个服务雪崩的案例代价非常大。跨机器自测要趁早。别总在127.0.0.1上测。两个终端在本机互通会掩盖掉地址绑定、防火墙、路由、MTU 等一系列问题。哪怕只是拿另一台同网段的机器连一下也能提前暴露不少东西。6.3 我个人建议的学习路径我的体会是套接字这层东西不适合先学完理论再动手也不适合抄完模板就完事比较舒服的节奏是三轮。第一轮跑通写一个阻塞的 TCP 回显服务用nc连通确认端口、地址、收发都正常。第二轮加约束自己定义一个带长度前缀的协议收发结构体强制做字节序转换用tcpdump或者strace验证字节层面的样子。第三轮改模型把阻塞改成非阻塞观察EAGAIN然后引入epoll理解事件驱动下的收发循环。这三轮走下来再去看那些高性能框架的代码会发现它们做的事情无非是把这三轮的细节做到极致缓冲区管理、零拷贝、批量收发、连接复用。基础概念打牢了上层的东西就只是工程取舍不再是黑魔法。最后分享一个我常用的自查习惯写完任何一段 socket 代码我都会问自己四个问题——这个数值转字节序了吗这个read会不会短读这个send失败我能处理吗对端关闭时我会不会崩四个问题全过代码基本就能在真实环境里扛住了。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表