ARTICLE DETAIL

资讯详情

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

Linux下C语言Socket编程精讲:从基础API到高并发服务

Linux下C语言Socket编程精讲:从基础API到高并发服务 1. 从“看过API”到“能写代码”中间到底差了点什么先交代一下背景。我接触Linux下的socket编程其实是被一个实际需求逼着上路的。当时手头有个服务需要接收大量设备的上报数据用现成的框架总觉得隔靴搔痒调起底层的网络行为来使不上劲。于是花了一整周从最基本的socket()函数开始一步步把整个过程捋了一遍。今天这篇内容就是想把我在这条路上踩过的坑、想明白的原理以及最终沉淀下来的一套可复用写法完完整整分享出来。先说结论socket编程本质上就是一个跨主机或同机跨进程的通信管道搭建过程。你不是在学一个高深莫测的魔法而是在学一套“怎么把两头的程序约定好、连接好、然后高效地搬运数据”的常规套路。任何一次socket通信无论后面挂的是MySQL、HTTP服务还是自定义协议底层都逃不开这几个动作创建socket也就是创建通信端点绑定地址和端口服务端通常需要客户端通常可省监听或发起连接收发数据关闭连接释放资源。这套流程本身并不复杂可为什么很多人看完教程一动手就一脸懵我在带新人的时候发现卡壳的人几乎都卡在同一个地方不知道每一步操作到底改变了什么状态也不知道出错时应该怎么定位。比如bind()明明调用了为什么connect()还是报错listen()的第二个参数填多大合适数据收发用read/write还是send/recv有什么区别这些问题背后其实都有一套非常具体的机制在支撑而多数教程只会告诉你“调用A函数传入参数B然后做C”完全不解释底层的状态变化。这篇文章我会换一种讲法直接对着代码讲状态对着错误讲排查尽量让新手能顺着思路走通整个流程。这篇文章适合谁看如果你是那种已经看过一些socket教程但真的动手写第一个Linux下的C语言socket程序就到处报错的人那这篇文章能帮你省下大量挨个翻手册的时间。哪怕你后面改用Python的socket模块、写Java的ServerSocket底层那套内核socket状态流转的逻辑是一样的原理通了换语言只是换语法。2. 先把必要条件铺好Linux下的环境与最小认知2.1 实验环境的准备写socket程序不太挑发行版Ubuntu、CentOS、Debian都行内核只要不是上古版本都没问题。我这次用的是Ubuntu 22.04内核版本5.15gcc版本11.4。这些信息你用下面这条命令就可以查到uname -a gcc --version编译C代码需要gcc和make99%的发行版默认都带没带的话装上也很方便sudo apt update sudo apt install build-essential有一点值得提socket编程在Linux下不需要任何额外的第三方库用的全部是内核提供的系统调用API所以编译的时候不需要-l链接任何奇怪的库最多加一个-lpthread如果用多线程。对新手来说这是件好事意味着只要你系统里能写helloworld就能写socket。2.2 少不了的几个头文件写socket程序时这几行#include基本是固定开场白#include stdio.h // 标准输入输出 #include stdlib.h // 标准库函数 #include string.h // 字符串处理 #include unistd.h // close()等POSIX接口 #include sys/types.h // 数据类型很多头文件依赖它 #include sys/socket.h // socket核心API #include netinet/in.h // sockaddr_in结构体、地址族常量 #include arpa/inet.h // inet_pton/inet_ntop地址转换函数新手经常漏掉netinet/in.h结果编译到sockaddr_in这类结构体时报“未定义”。如果你看到编译器报了一堆和struct sockaddr相关的error第一反应先去检查头文件而不是检查逻辑。2.3 理解内核为你准备的数据结构写socket代码时你打交道最多的是两个结构体struct sockaddr { sa_family_t sa_family; // 地址族比如AF_INET char sa_data[14]; // 14字节的协议地址具体格式看地址族 }; struct sockaddr_in { sa_family_t sin_family; // 地址族AF_INET in_port_t sin_port; // 端口号必须用网络字节序 struct in_addr sin_addr; // IPv4地址网络字节序 char sin_zero[8];// 填充字段保持和sockaddr同样大小 };刚接触的人容易困惑一个问题bind()明明接受的是struct sockaddr*为什么我们传的是struct sockaddr_in*还要强转答案很简单sockaddr是一个通用容器内核只负责从这里取它需要的字节而sockaddr_in才是IPv4场景下的具体格式。你把sockaddr_in强转成sockaddr告诉内核“我这包字节是按IPv4格式排的”内核一看sin_family AF_INET就知道按IPv4的规则来解析后面的内容。这是一种非常老派的C语言泛型写法理解这个设计后面看源码时就不会一脸茫然。另外两个必须养成的习惯端口和IP在填入结构体前要完成字节序转换。htons()负责把本机字节序转成网络字节序htonl()处理32位整型。对应地从网络上读回来的端口要显示给用户用ntohs()转回来。不转换的后果很隐蔽本机通信可能没问题一旦跨主机就出现“端口对不上”的诡异现象。struct sockaddr_in在声明之后最好用memset清零或者用bzero。书面说法是“防止栈上残留数据导致不确定行为”直白点说不清理可能在解析时读到脏数据排查起来很折磨。3. socket的核心机制文件描述符与通信管道的建立3.1 一切都从文件描述符开始Linux有个经典论断一切都是文件。socket在Linux内核里的体现就是一个文件描述符fd和其他你打开的文件描述符没有本质区别。你在/proc/pid/fd目录下能看到每个进程持有的全部fd其中就能找到socket对应的条目后面带个socket:[inode号]这样的标记。这就引出一个非常重要的推论所有用来操作普通文件的系统调用如read()、write()、close()一样可以操作socket。很多老代码里收发数据用的就是read/write而不是send/recv功能上没区别。区别在于send/recv多了一个flags参数可以让我们控制一些特殊行为比如MSG_DONTWAIT做非阻塞发送、MSG_PEEK偷看数据而不取走并且返回值和错误码比read/write更能精准反映网络层的具体状况。所以实际写代码我一般推荐用send/recv不是说read/write不对而是recv在读取时的语义更清晰、更可控。既然是fd那就要遵循fd的通行法则用完之后必须close()。忘记关闭fd会累计占用系统资源最终把进程可用fd额度耗尽。你可以用ulimit -n看当前进程能打开的最大fd数默认通常是1024。做高并发服务时这个数字要专门调不然撑两三百个连接就卡死了。3.2 socket的创建函数socket()socket()的作用是创建一个通信端点函数原型int socket(int domain, int type, int protocol);三个参数的含义domain协议族决定了地址类型。写IPv4网络程序就是AF_INETIPv6用AF_INET6。很多人纠结AF_INET和PF_INET的区别解释起来比较复杂总之你用AF_INET就对了。type套接字类型。SOCK_STREAM提供有序、可靠、双向字节流底层走TCPSOCK_DGRAM提供无连接、不可靠的数据报底层走UDP。想用TCP就必须选SOCK_STREAM。protocol指定具体协议。传0时内核会在给定domain和type下自动选择一个合适的协议——SOCK_STREAM就选TCPSOCK_DGRAM就选UDP。99%的场景传0就够。调用成功返回一个不小于0的fd失败返回-1并设置errno。新手写的第一份socket代码几乎不会检查返回值这是大忌。**socket编程的每一步系统调用都可能失败每一处都必须判错。**这不是代码风格问题而是你将来定位线上问题的一个个关键锚点。我见过最惨烈的一次排障服务端代码一路不判错压根没注意到bind()早就失败了最后客户端connect()超时排查了大半天才发现是端口被占导致bind()没成功。所以从第一个程序开始就养成每步判错的习惯。3.3 服务端视角bind、listen、accept服务端的固定三连第一步bind()。把socket fd和一个具体的地址IP端口绑定在一起。这个动作的意义在于告诉内核“以后凡是发到这个IP这个端口的数据都从我这个fd上读。”绑定前要把地址信息塞进struct sockaddr_instruct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); // 绑定所有网卡IP server_addr.sin_port htons(8080); if (bind(server_fd, (struct sockaddr*)server_addr, sizeof(server_addr)) 0) { perror(bind failed); exit(EXIT_FAILURE); }这里解释一下两个关键点INADDR_ANY是值为0的特殊地址代表“本机的所有IP地址”。如果服务器只有一块网卡这么写省事如果有多块网卡而你只想接受某一块网卡上的连接就要换成具体的IP用inet_pton(AF_INET, 192.168.1.10, server_addr.sin_addr)来转换填充。bind()失败最常见的原因是端口被占用。排除了端口冲突还有一个隐蔽原因TIME_WAIT状态下的旧连接会占用端口。后面我会专门讲这个坑。第二步listen()。把socket从未连接状态切换到被动监听状态同时指定内核维护的连接队列上限if (listen(server_fd, 128) 0) { perror(listen failed); exit(EXIT_FAILURE); }第二个参数128叫backlog很多新手不理解它的含义。它不是“最多能接受128个并发连接”而是“内核为这个监听socket排队的等待accept的连接数上限”。换个方式理解客户端已经发出连接请求但你的程序还没调用accept()取走它之前这些连接在内核缓存队列里排队。队列满后新的连接请求会被内核直接拒绝客户端表现为Connection refused或连接超时而不是无限期等待。所以这个值直接决定了服务端的“抗突发连接能力”。瞬时并发高的服务把这个值调大一些是合理的。Linux内核在2.2以后还引入了一个机制让listen()的backlog同时作用于syn队列和accept队列细节更复杂但作为使用者你只需要记住它是排队上限不是并发上限。第三步accept()。从已完成连接队列里取出一个连接返回一个全新的fd代表这个具体连接struct sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); int client_fd accept(server_fd, (struct sockaddr*)client_addr, client_len); if (client_fd 0) { perror(accept failed); }很多人容易搞混一个概念listen的fd本身是不能收发数据的能收发数据的是accept返回的这个新fd。服务端要想同时服务多个客户端靠的就是对每个客户端连接持有不同的fd。监听fd永远只有一个它负责“接客”每个客人进来以后对接的fd负责“服务”。accept()返回的客户端IP和端口存在client_addr里调试的时候很有用。你可以用inet_ntop把二进制IP转回字符串打出来看看到底是谁连了你的服务。3.4 客户端视角socket connect客户端的操作比服务端简化了很多不需要bind由内核自动分配一个临时端口、不需要listen、不需要accept核心就两步创建socket然后connectstruct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(8080); inet_pton(AF_INET, 127.0.0.1, server_addr.sin_addr); if (connect(client_fd, (struct sockaddr*)server_addr, sizeof(server_addr)) 0) { perror(connect failed); exit(EXIT_FAILURE); }connect()干了什么它发起一个到目标IP端口的连接请求同时内部会自动选择一个可用的本地端口作为源端口。这也是为什么客户端不常需要bind()——让内核临时分配更省事也避免端口冲突。如果connect()返回成功代表三次握手已经完成连接处于可用状态。如果失败errno会告诉你具体原因最常见的两种Connection refused目标端口上没有进程在监听你的SYN包发过去直接被内核回了一个RST。Connection timed outSYN包发出去了但没有收到任何响应通常是目标IP不可达或者被防火墙静默丢弃了。这两种错误很值得重视因为它们是判断“网络问题还是服务问题”的第一层线索。4. 数据收发与全双工交互send/recv怎么用才稳4.1 收发数据的基本姿势连接建立之后数据收发就是这个连接上fd的读写操作。TCP是一个全双工的字节流协议你可以同时通过同一个连接发数据和收数据。自己实现一个简单的echo服务端客户端数据收发部分逻辑大致是这样服务端char buffer[1024]; ssize_t n recv(client_fd, buffer, sizeof(buffer) - 1, 0); if (n 0) { buffer[n] \0; printf(Received: %s\n, buffer); send(client_fd, buffer, n, 0); } else if (n 0) { printf(Client closed connection\n); } else { perror(recv failed); }客户端char buffer[1024]; const char *msg Hello, Server!; send(client_fd, msg, strlen(msg), 0); ssize_t n recv(client_fd, buffer, sizeof(buffer) - 1, 0); if (n 0) { buffer[n] \0; printf(Server replied: %s\n, buffer); }这里要重点敲一下黑板recv()的返回值有三种情况每种都是重要信号。返回值大于0收到n个字节。这是最常见的正常情况。返回值等于0对方正常关闭了连接。客户端发完数据后调用close()服务端的recv()就会返回0。这等价于收到一个“EOF”标记你应该主动关闭对应的fd结束对这个客户端的服务。返回值小于0出错。最常见的是EINTR被信号中断和EAGAIN/EWOULDBLOCK非阻塞模式下暂时无数据可读。所以判断recv()返回值时不能简单写成if (n 0)就报错应该先判断是否为0再判断是否为负。很多线上bug就是“把对方正常断开连接当成了错误”造成的日志刷屏全是没意义的报错信息。4.2 注意send()并不保证一次发完再敲一次黑板send()返回的字节数可能是0到len的任何一个值不保证一次性发完len个字节。TCP提供的是字节流它只保证你调用send()时交给内核缓冲区的数据会被有序、不重复地送达对端但不保证你一次send()的整段数据都进了内核发送缓冲区。举个例子你想send(fd, buf, 5000, 0)由于内核缓冲区空间不足发送缓冲区已满、对端接收速度跟不上返回值可能只有1200剩余3800字节需要你循环再次发送ssize_t send_all(int fd, const char *buf, size_t len) { size_t sent 0; while (sent len) { ssize_t n send(fd, buf sent, len - sent, 0); if (n 0) { if (errno EINTR) { continue; } return -1; } sent n; } return len; }同理数据接收也面临“粘包/分包”的表象问题。TCP是字节流它根本不知道你的业务报文边界在哪里。你自己约定一条报文是10字节也好、以\n结束也好收发双方都必须严格按这个约定去拆包。很多新手在本地测试时发现send一次recv一次刚好对上就以为TCP保住了消息边界一旦到了复杂网络环境就发现问题这就是对TCP流式模型的误解。记住这句话TCP不管消息边界应用层自己管。很多人在实现应用层协议时会采用“包头包体”的经典格式固定长度的包头里写上整个报文的长度接收方先读固定长度的包头解析出body长度再循环读取对应长度的body。这种做法能彻底规避粘包和半包的问题值得在初学阶段就养成。4.3 用shutdown优雅关闭而不是直接close关闭连接新手最常见的问题是直接close(fd)了事。这不一定是错的但不够精细。close(fd)有两个潜在问题它会把fd从当前进程的fd表中移除同时将socket引用计数减1。如果这个fd被fork出来的多个子进程共享只有最后一个close才真正触发连接的拆除。它不能单独关闭读半边或写半边。有时候你只想告诉对方“我没有数据要发了”但仍想继续收对方的后续数据这时close()做不到。shutdown()专门解决这些问题int shutdown(int sockfd, int how);how有三种取值SHUT_RD关闭读半边后续recv()直接返回0。SHUT_WR关闭写半边后续send()会触发SIGPIPE信号对端recv()会返回0。这是最常用的用法相当于告诉对方“我说完了该你了”。SHUT_RDWR读写都关效果类似close()但不会释放fd。shutdown()还有一个特性和close()有本质区别即使socket的fd被多个进程共享shutdown()也会立刻影响整个连接。在需要精细控制连接状态的时候优先考虑shutdown()。这里还要提一个非常常见的坑向一个已关闭读半边的连接写数据会导致内核向你的进程发送SIGPIPE信号默认行为是直接终止进程。如果你没处理这个信号服务端可能莫名其妙地崩溃连一条日志都来不及打印。实际写网络程序时我通常在一开始就屏蔽或忽略SIGPIPEsignal(SIGPIPE, SIG_IGN);5. 实战从零写一个支持多客户端连接的TCP服务端5.1 单线程版echo服务端的问题在哪先把前面的知识点合成一个最小可用的单线程echo服务端逻辑很简单接受一个客户端连接循环接收数据再原样发回去直到对方关闭连接。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/types.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include signal.h #define SERVER_PORT 8080 #define BUFFER_SIZE 1024 int main() { signal(SIGPIPE, SIG_IGN); int server_fd socket(AF_INET, SOCK_STREAM, 0); if (server_fd 0) { perror(socket); exit(EXIT_FAILURE); } int opt 1; if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)) 0) { perror(setsockopt); exit(EXIT_FAILURE); } struct sockaddr_in server_addr; 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(SERVER_PORT); if (bind(server_fd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(bind); exit(EXIT_FAILURE); } if (listen(server_fd, 128) 0) { perror(listen); exit(EXIT_FAILURE); } printf(Server listening on port %d...\n, SERVER_PORT); while (1) { struct sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); int client_fd accept(server_fd, (struct sockaddr *)client_addr, client_len); if (client_fd 0) { perror(accept); continue; } printf(New client connected: %s:%d\n, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); char buffer[BUFFER_SIZE]; while (1) { ssize_t n recv(client_fd, buffer, sizeof(buffer) - 1, 0); if (n 0) { buffer[n] \0; printf(Received: %s\n, buffer); if (send(client_fd, buffer, n, 0) 0) { perror(send); break; } } else if (n 0) { printf(Client %s:%d closed connection\n, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); break; } else { perror(recv); break; } } close(client_fd); } close(server_fd); return 0; }这段代码单看功能是完整的但有一个致命问题它是线性的。accept()返回一个客户端fd后主流程就一头扎进这个客户端的recv循环里了在此期间无法accept新的连接。如果第一个客户端的逻辑是连上后一直不发数据第二个客户端想连就只能干等。所以单线程版只适合教学演示和最简单的调试真实场景下几乎不能用。5.2 用fork实现多客户端处理最朴素的多客户端方案是每次accept()返回一个fd后fork()一个子进程专门处理这个连接父进程立刻回到accept()接着等新连接。代码如下while (1) { int client_fd accept(server_fd, (struct sockaddr *)client_addr, client_len); if (client_fd 0) { perror(accept); continue; } pid_t pid fork(); if (pid 0) { perror(fork); close(client_fd); continue; } if (pid 0) { // 子进程处理客户端 close(server_fd); // 子进程不关心监听fd先关掉 handle_client(client_fd); close(client_fd); exit(0); } else { // 父进程关闭客户端fd回到accept close(client_fd); } }这里有两个非常重要的细节值得新手动脑想想为什么子进程里要close(server_fd)因为fork会复制父进程的全部fd子进程一出生就同时持有server_fd和client_fd。如果子进程不关闭server_fd那么这个监听socket的引用计数就不会变成0。虽然子进程接下来不会accept但服务端进程组里多了一个持有监听fd的存活进程。父进程退出、想重启服务时监听fd因为被子进程持有而得不到释放bind()就会报“Address already in use”。同理父进程也必须close(client_fd)否则父进程手里攒着一堆不再使用的客户端fd数量一多就会被ulimit -n卡住。子进程处理完客户端后要不要wait如果不wait()子进程退出后会变成僵尸进程长期堆积会吃光进程表。可以在父进程里注册SIGCHLD信号处理函数或者循环waitpid(-1, NULL, WNOHANG)。严谨的写法必须处理很多人一开始不管跑几天才发现系统上密密麻麻全是僵尸进程。5.3 用线程实现多客户端注意点更多fork方案写起来简单但开销大、进程间隔离重更现代的做法是线程。线程方案的骨架是这样void *handle_client(void *arg) { int client_fd *(int *)arg; free(arg); // ...处理和客户端收发数据的逻辑... close(client_fd); return NULL; } while (1) { int client_fd accept(...); int *pfd malloc(sizeof(int)); *pfd client_fd; pthread_t tid; pthread_create(tid, NULL, handle_client, pfd); pthread_detach(tid); // 线程结束自动释放资源 }三个容易翻车的点传参必须malloc。我见过无数人写pthread_create(tid, NULL, handle_client, client_fd)然后在线程里用这个地址这是新手最容易犯的并发错误。因为client_fd是栈上变量父线程下一次循环就可能改变它的值子线程读到的可能已经是下一个客户端的fd了。正确做法是堆上malloc一份线程拿到后自己管理。要设置线程分离。pthread_join()可以在需要等线程结束时用但多客户端的场景通常不需要逐个join用pthread_detach()让线程结束后自动释放资源更省心。不detach也不join的线程退出时不会释放资源时间久了同样会出问题。线程之间的共享变量需要加锁。比如计数统计、日志输出这类在多线程下共享的操作不做好同步会出现各种难以复现的奇怪行为。网络编程和并发编程天然交织在一起线程模型下这些坑是躲不掉的。5.4 多路复用从select到epollfork和线程本质上都是“一个连接对应一个执行流”连接数一多上千上万线程或进程的资源开销、调度开销就上来了。真正的工业级方案是I/O多路复用用一个线程同时监听很多fd上的可读可写事件哪个fd有事件了就处理哪个。select()是最老的多路复用接口上限受制于FD_SETSIZE默认1024每次调用都要把整个fd集合从用户态拷贝到内核态监听上千个fd时性能惨不忍睹。epoll是Linux独有的高效方案核心三个函数epoll_create()创建一个epoll实例epoll_ctl()往epoll实例里注册/修改/删除要监听的fdepoll_wait()等待已注册的fd上有事件发生返回就绪的fd列表。epoll性能高的关键在于两个设计内核不再需要每次调用时扫描全部fd而是通过红黑树管理注册的fd监听的数量几乎没有上限受内存限制就绪的fd会保存在内核的事件表中epoll_wait()返回时直接把就绪列表拷贝给用户不需要遍历几千个fd去逐个询问“你有没有事件”。下面这段代码是epoll模式服务端的核心骨架可以拿去做简单测试int epfd epoll_create(1); struct epoll_event ev; ev.events EPOLLIN; // 只关心可读事件 ev.data.fd server_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, ev); struct epoll_event events[128]; while (1) { int n epoll_wait(epfd, events, 128, -1); for (int i 0; i n; i) { if (events[i].data.fd server_fd) { int client_fd accept(server_fd, (struct sockaddr *)client_addr, client_len); if (client_fd 0) { ev.events EPOLLIN; ev.data.fd client_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, ev); } } else { // 处理客户端fd上的可读事件 handle_client_event(events[i].data.fd); } } }关于epoll有两点特别提醒默认的epoll是**水平触发Level Triggered模式只要fd上还有数据没读完epoll_wait()就一直报告这个fd可读。这种模式好写、不易出错适合绝大多数场景。还有一种边缘触发Edge Triggered**模式只在状态变化时通知一次要求你一次性把数据读干净处理不当就丢数据新手不要急着用。单线程epoll的事件循环里回调函数不能做耗时操作。比如你收到一段数据要去查数据库、调外部接口这些操作不阻塞事件循环的话其他连接会被拖累。业界典型的解法是把耗时任务丢到线程池去执行主线程只负责事件收发。我在生产环境里做的网络服务基本就是epoll 线程池的组合这套方案在单机几万连接下也能稳得住。所以给新手的建议是先理解fork和线程方案它们能帮你深刻理解“一个fd对应一个连接”这个概念但真正要写能扛压的服务直接学epoll。6. 实际编程中高频踩坑点一步步还原排查过程6.1 错误一bind: Address already in use这是新手问得最多的问题没有之一。明明上一次程序已经退出重新运行却报这个错误。原因出在TCP的TIME_WAIT状态上。当一个TCP连接被主动关闭时主动关闭方会进入TIME_WAIT状态默认持续约2分钟具体由系统参数决定。在这段时间里连接的四元组源IP、源端口、目的IP、目的端口会被内核标记为“不可复用”。如果你的服务端程序每次启动都重新绑定同一个端口前一次运行留下的TIME_WAIT连接还在bind()就失败。解决方式是调用setsockopt()设置SO_REUSEADDRint opt 1; setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt));这个选项允许内核在端口处于TIME_WAIT状态时仍然允许绑定对服务端是几乎必须的设置。我在示例代码里已经加上了实际项目中建议养成习惯每次bind之前都加。6.2 错误二Connection refused / Connection timed out客户端connect()连不上要看具体报的是哪个Connection refused说明目标IP可达但目标端口上没有服务在听。可能原因服务端程序没启动、端口写错、服务端监听地址和实际地址不匹配比如服务端绑定了127.0.0.1你从局域网其他机器去连它的内网IP。用ss -lntp | grep 端口可以快速确认端口监听情况。Connection timed out说明连接请求发出去后没人应答。优先怀疑网络不通、目标IP写错、防火墙丢弃SYN包。在服务器上先ping目标IP再从客户端用telnet IP 端口或nc -vz IP 端口做通联测试能很快缩小范围。有一次我排查一个服务不可达的问题服务端日志干干净净客户端就是连不上。后来发现是云安全组的入站规则里压根没放行这个端口。这种问题在云环境下尤其常见——你以为防火墙只是本机iptables其实还有一层云厂商的规则在管着你。6.3 错误三send触发SIGPIPE进程直接没了这个我在前面已经提过信号处理这里再还原一次踩坑现场。当时写一个简单的转发服务没处理SIGPIPE。客户端异常断开后比如直接拔网线服务端不知道连接已经失效继续往这个fd上send()内核发现对端已不可达会向进程发送SIGPIPE信号。默认行为是终止进程。于是服务端好端端跑着突然就没了日志文件里啥都没有排查起来极其迷惑。解决方式就是那句signal(SIGPIPE, SIG_IGN);。忽略这个信号后send()会返回-1并设置errnoEPIPE代码就能正常感知到连接异常并做清理。凡是用TCP的地方强烈建议一上来就忽略SIGPIPE。6.4 错误四数据粘包、条数对不上客户端发了两条报文服务端一个recv()全收走了这就是粘包。或者一条报文被拆成两次recv()这就是半包。前面说过TCP是字节流不维护业务消息边界。解决办法从根上来说是应用层协议约定两种最常见的做法固定长度约定每条消息都固定N字节接收方每次读够N字节才解析。适合定长数据比如GPS坐标、传感器采样值。头部长度字段每条消息前4字节表示整个消息长度接收方先读4字节再按指示读剩余部分。我自己的习惯是选第二种兼容性好扩展性也强。对新人来说关键是要意识到“一次recv不等于一条消息”这个事实并尽早用协议去约束。6.5 一个直观的抓包工具建议排查socket问题强烈建议学会用tcpdump和ss。ss -lntp列出监听端口状态ss -tn state established看当前连接状态tcpdump -i any -nn -vv port 8080看指定端口的原始数据包流动。有一次一个“数据错乱”的问题困扰了我好久逻辑上看不出毛病。用tcpdump一看才发现是自己上次调试时给消息头部的字节序搞反了htons写成了ntohs小端火星文直接发到了对端对端解析出来的长度字段成了天文数字。这类问题在纯逻辑代码里很难发现抓包一眼就能照出原形。7. 进阶方向与最后想说的一些体会这篇文章覆盖的还只是socket编程的起点。你在Linux下写网络程序后面迟早要遇到至少这几个方向提前说一下方便你自己规划学习路线非阻塞IO与事件驱动把fd设为非阻塞配合epoll的ET模式把读写精细管理起来这是高性能网络服务的基础。协议设计高扩展性的协议前缀、协议版本、消息类型、序列化方式这些看起来不如API炫酷却决定了系统能走多远。并发模型的取舍单线程事件循环还是多线程各自处理连接还是主从reactor线程池每种模型都有各自擅长的业务场景没有一个万能答案。网络异常处理心跳机制、超时探测、半打开连接的回收跨地域部署时这些才是大坑。选型而不重复造轮子如果要上生产先看看成熟的高性能网络库比闷头自己造轮子更稳。但前提是你得先把这里的底层机制搞明白否则出了奇奇怪怪的线上问题别人说“缓冲区不够”“fd泄漏”“惊群效应”时你完全接不上话。说回最开始那个问题——从“看过API”到“能写代码”差别到底在哪我的体会是差别不在于你默写得出几个函数签名而在于你能不能在看代码时迅速建立起“当前进程里有哪些fd、每个fd处于什么状态、内核为它们做了什么数据结构”的图景。socket编程的本质是管理状态fd的状态、连接的状态、收发缓冲的状态、进程资源的状态。当你能把一次accept()、一次send()、一次recv()都翻译成一张清晰的内核状态变化图时很多看似诡异的问题都会变得非常直白。作为一个被socket虐过很多轮的人我最后想给你的建议只有一条不要怕报错报错是最好的老师但前提是你得尽早学会把errno和perror用起来。每一行代码都判错、把每个异常分支都走一遍你花在排障上的时间会大幅减少。先把上面那些字节序、粘包、SIGPIPE、TIME_WAIT这些基础问题练熟你再去看任何网络库的源码时都会觉得亲切不少。
返回列表