简介:这是一份面向Linux网络编程初学者与进阶开发者的Socket编程学习资料,围绕TCP/IP协议栈下进程间通信的核心机制展开,帮助读者理解Socket接口原理并掌握实际编码方法。内容涵盖网络中进程通信的标识方式、Socket概念起源与文件抽象模型,以及socket()、bind()、listen()、connect()、accept()、read()/write()、close()等基本函数的用法,并详细解析TCP三次握手建立连接与四次挥手释放连接的过程,最后附有实践示例供动手演练。资源包为1个docx文档,约77KB,以图文笔记形式组织,结构清晰,便于按章节顺序阅读或作为查阅手册。目前已有241人学习,适合希望系统梳理Socket编程知识、为网络应用开发打基础的读者参考。
1. 从一份“老派”Socket 实例说起:为什么它仍是网络编程的活教材
翻到一份 Linux Socket 编程的实例包,第一反应往往是“这年头谁还手写 socket”。但真到排查线上服务连接超时、半连接队列溢出、或者客户端connect返回EINTR的时候,能救命的恰恰是这些底层函数的行为细节。这份资源围绕 TCP 场景,把socket()、bind()、listen()、connect()、accept()、recv()/send()、close()串成一条完整链路,并配了一个可直接编译运行的服务端/客户端 Demo。它适合两类人:一是刚接触网络编程、想搞懂“三次握手到底发生在哪个函数里”的新手;二是写过业务代码、但对accept返回的新描述符和监听描述符的区别始终模糊的熟手。下面按“资源是什么 → 怎么跑起来 → 参数怎么调 → 坑在哪”的顺序拆一遍。
2. 拆解实例代码结构:mysocket 封装与编译链路
2.1 四个文件的分工与依赖关系
这份实例没有把逻辑堆在一个main.cpp里,而是拆成mysocket.h、mysocket.cpp、server.cpp、client.cpp四个文件。mysocket类把服务端和客户端的连接建立、消息收发封装成成员函数,server.cpp和client.cpp只负责调用。这种拆法在课程设计里很常见,好处是服务端和客户端复用同一套 socket 操作,改端口或改 IP 时只动一处。
先看头文件暴露的接口:
// mysocket.h #ifndef _MYSOCKET_H #define _MYSOCKET_H #include <sys/socket.h> class mysocket { public: mysocket(); ~mysocket(); // 客户端:创建 socket 并 connect 到指定端口 bool createclient(int domain, int soctype, int protype, int port); // 服务端:创建 socket、bind、listen bool createserver(int domain, int soctype, int protype, int port); void recvmsg(); // 服务端循环接收 void sedmsg(); // 客户端循环发送 private: int m_idomain; // 协议族,如 AF_INET int m_isoctype; // socket 类型,如 SOCK_STREAM int m_iprotype; // 协议,通常传 0 int m_isocket; // socket 描述符 }; #endif这里把domain、soctype、protype三个参数存成成员变量,是为了在createclient和createserver里统一传给socket()。注意m_isocket只保存了监听或客户端主 socket,服务端accept返回的已连接描述符是局部变量connfd,没有存进类里——这是后面要重点说的一个设计取舍。
2.2 服务端创建流程:bind 与 listen 的参数落点
createserver的职责是把 socket 变成被动监听状态。核心代码:
bool mysocket::createserver(int domain, int soctype, int protype, int port) { m_idomain = domain; m_isoctype = soctype; m_iprotype = protype; m_isocket = socket(m_idomain, m_isoctype, m_iprotype); if (-1 == m_isocket) { printf("create socket error\n"); return false; } struct sockaddr_in servaddr; memset(&servaddr, 0, sizeof(servaddr)); servaddr.sin_family = domain; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听本机所有网卡 servaddr.sin_port = htons(port); // 主机序转网络序 if (bind(m_isocket, (struct sockaddr*)&servaddr, sizeof(servaddr)) == -1) { printf("bind socket error\n"); return false; } if (listen(m_isocket, 10) == -1) { // backlog = 10 printf("listen socket error\n"); return false; } printf("======waiting for client's request======\n"); return true; }几个参数值得单独拎出来。INADDR_ANY表示绑定到0.0.0.0,也就是本机所有网卡地址,这样局域网内其他机器也能连进来;如果只想本机测试,可以换成inet_addr("127.0.0.1")。htons(port)把主机字节序转成网络字节序,这一步漏掉的话端口号会变成另一个值,服务端和客户端对不上。listen的第二个参数backlog传了 10,它控制的是已完成连接队列和半连接队列的长度上限,不是“最大连接数”。高并发场景下这个值偏小会导致新连接被丢弃或客户端收到 RST,后面避坑章节会展开。
2.3 客户端创建流程:inet_pton 与 connect 阻塞点
客户端侧的关键是地址转换和connect调用:
bool mysocket::createclient(int domain, int soctype, int protype, int port) { m_idomain = domain; m_isoctype = soctype; m_iprotype = protype; struct sockaddr_in servaddr; m_isocket = socket(m_idomain, m_isoctype, m_iprotype); if (-1 == m_isocket) { printf("create socket error\n"); return false; } memset(&servaddr, 0, sizeof(servaddr)); servaddr.sin_family = domain; servaddr.sin_port = htons(port); // 这里要改成实际服务端的 IP if (inet_pton(AF_INET, "192.168.1.18", &servaddr.sin_addr) <= 0) { printf("inet_pton error for 192.168.1.18\n"); return false; } if (-1 == connect(m_isocket, (struct sockaddr*)&servaddr, sizeof(servaddr))) { printf("connect to server error 192.168.1.18\n"); return false; } printf("======Begin sending msg to server=====\n"); return true; }inet_pton把点分十进制的 IPv4 字符串转成二进制网络序地址,比老式的inet_addr更安全,因为它能区分255.255.255.255和错误返回值。connect在 TCP 下会触发三次握手,默认是阻塞的:内核发 SYN,等对方回 SYN+ACK,再回 ACK,然后connect才返回。如果服务端没开或者防火墙拦了,这里会卡住一段时间才报错。实例里把 IP 硬编码成192.168.1.18,换环境必须改,否则连不上。
2.4 编译命令与运行顺序
四个文件用 g++ 直接编译即可,不需要额外链接库:
# 编译服务端 g++ -o server server.cpp mysocket.cpp # 编译客户端 g++ -o client client.cpp mysocket.cpp # 先启动服务端,再启动客户端 ./server # 另开一个终端 ./client编译时如果报undefined reference to 'mysocket::...',通常是漏掉了mysocket.cpp。运行顺序不能反:先跑客户端会直接connect失败,因为服务端还没listen。服务端启动后打印waiting for client's request,客户端启动后打印Begin sending msg to server,服务端侧会看到rec msg from client和递增的msg = N。
3. 从函数调用看 TCP 三次握手与四次挥手:accept 到底卡在哪
3.1 三次握手在 socket 函数中的映射
很多人背过“三次握手”,但说不清它对应代码里的哪一步。按这份实例的调用顺序,映射关系是这样的:
| 握手步骤 | 内核动作 | 应用层函数状态 |
|---|---|---|
| 第一次 | 客户端发 SYN J | connect()阻塞中 |
| 第二次 | 服务端回 SYN K + ACK J+1 | accept()阻塞中 |
| 第三次 | 客户端回 ACK K+1 | connect()返回,accept()随后返回 |
关键结论:客户端的connect在第二次握手后、收到 SYN+ACK 时就返回了,此时它已经可以发数据;服务端的accept要等到第三次握手的 ACK 到达才返回。这意味着服务端在accept返回之前,连接其实已经在内核里建立好了,只是应用层还没拿到那个已连接描述符。实例里recvmsg的while(1)循环中accept失败会continue,这个写法在真实服务里要小心,因为accept失败可能是EINTR也可能是资源耗尽,无脑重试会打满 CPU。
3.2 四次挥手与 close 的引用计数
释放连接比建立多一次,因为 TCP 是全双工的,两个方向要各自关闭。实例里客户端sedmsg是死循环,服务端recvmsg在recv返回 0 时退出内层循环,但外层while(1)又会回到accept等下一个连接。close的行为容易被误解:
// close 只是把描述符引用计数减一 // 计数归零才真正发 FIN int close(int fd);服务端accept返回的connfd是内核新建的描述符,和监听描述符m_isocket是两个独立对象。关闭connfd不影响m_isocket继续监听。实例里connfd是局部变量,recvmsg内层循环结束后没有显式close(connfd),依赖进程退出或下一次循环覆盖——这在短生命周期 Demo 里能跑,但长跑服务会泄漏描述符。常见做法是在内层循环结束后加close(connfd),把每个连接的生命周期收干净。
3.3 recv 返回值的三种含义与处理
recvmsg里的接收循环是理解 TCP 流式语义的好例子:
while ((n = recv(connfd, buff, 1023, 0)) > 0) { memset(buff, 0, 1024); buff[n] = '\0'; printf("connfd=%d; rec msg from client: %s.msglen=%d\n", connfd, buff, n); n = 0; }recv返回值分三种:大于 0 表示读到的字节数,等于 0 表示对端关闭了连接(收到 FIN),小于 0 表示出错。这里只处理了大于 0 的情况,等于 0 时循环退出,小于 0 时也退出但没有打印错误。真实场景下要区分EINTR(被信号中断,可重试)、ECONNRESET(对端复位)、EAGAIN(非阻塞模式下无数据)。另外recv不保证一次读完一条“消息”,TCP 是字节流,msg = 1.\n和msg = 2.\n可能被合并成一次recv返回,也可能被拆成两次。实例里靠\n和printf观察,生产代码需要自己定长度前缀或分隔符来切包。
4. 参数调优与边界:backlog、缓冲区与阻塞模式
4.1 backlog 的真实含义与设置建议
listen(m_isocket, 10)里的 10 经常被误认为“最多 10 个连接”。实际上内核为监听 socket 维护两个队列:半连接队列(收到 SYN、还没完成三次握手)和已完成连接队列(三次握手完成、等accept取走)。backlog影响的是已完成队列的长度上限,不同内核版本对半连接队列的处理还不一样。当队列满时,新来的 SYN 可能被丢弃,客户端会重传,表现为连接变慢或超时。
设置建议:Demo 里 10 够用;真实服务按并发量估算,常见做法是设成somaxconn的值或略小。Linux 上可以用sysctl net.core.somaxconn查看系统上限,默认常见是 128 或 4096。如果accept调用不够快,队列会堆积,此时应该优化accept后的处理逻辑,而不是单纯调大backlog。
4.2 发送与接收缓冲区的查看和调整
send和recv的第三个参数是缓冲区长度,但内核还有一层 socket 缓冲区。可以用setsockopt调整:
int sndbuf = 64 * 1024; int rcvbuf = 64 * 1024; setsockopt(m_isocket, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf)); setsockopt(m_isocket, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));SO_SNDBUF影响send在阻塞模式下能一次性写入多少数据而不阻塞;SO_RCVBUF影响内核能缓存多少未读数据。调大能提升吞吐,但会占用更多内存。实例里没设这两个选项,用的是系统默认值,小消息场景没问题。如果发送大块数据发现send频繁部分返回,可以先查SO_SNDBUF当前值,再决定是否调整。
4.3 阻塞与非阻塞的切换时机
实例全程用阻塞模式:connect阻塞到握手完成,accept阻塞到有新连接,recv阻塞到有数据。阻塞模式代码简单,但一个连接卡住会影响后续连接处理。常见改造是把监听 socket 设成非阻塞,用select/poll/epoll做多路复用:
int flags = fcntl(m_isocket, F_GETFL, 0); fcntl(m_isocket, F_SETFL, flags | O_NONBLOCK);设成非阻塞后,accept在没有新连接时返回 -1 并置errno为EAGAIN或EWOULDBLOCK,需要配合事件循环使用。这份实例作为入门材料保持阻塞模式是合理的,先理解同步流程,再过渡到多路复用。如果直接拿它改高并发服务,第一刀就是给监听 socket 加非阻塞并引入epoll。
5. 避坑与排查:那些让 Demo 跑不起来的细节
5.1 端口被占用导致 bind 失败
现象:服务端启动打印bind socket error,程序退出。原因:6666 端口已被其他进程占用,或者上一次运行的服务端没退干净。解决:先用ss -tlnp | grep 6666或netstat -tlnp | grep 6666查占用进程,杀掉或换端口。换端口时记得客户端createclient的port参数同步改,两边不一致会表现为客户端connect失败。
5.2 客户端 IP 写死导致 connect 超时
现象:客户端启动后卡住,过很久打印connect to server error。原因:inet_pton里的192.168.1.18不是当前服务端所在机器的 IP,或者服务端和客户端不在同一网段。解决:把 IP 改成服务端实际地址;本机测试直接用127.0.0.1。如果服务端在另一台机器,还要确认防火墙放行了对应端口,否则 SYN 包被丢弃,connect会一直重传到超时。
5.3 字节序转换遗漏导致端口错乱
现象:服务端bind成功,客户端connect也成功,但双方打印的端口对不上,或者连到了奇怪的服务。原因:sin_port赋值时忘了htons,或者sin_addr忘了htonl。解决:牢记“赋给 socket 地址结构体的整数必须转网络序”。htons用于 16 位端口,htonl用于 32 位地址。inet_pton内部已经处理了地址的字节序,不需要再手动转。
5.4 accept 返回的新描述符被误当监听描述符
现象:服务端处理完一个连接后,第二个客户端连不上,或者recv报错。原因:把accept返回的connfd存进了m_isocket,覆盖了监听描述符,导致后续accept在已连接 socket 上调用。解决:监听描述符和已连接描述符分开保存,accept的第一个参数永远是监听描述符,返回值才是新连接。实例里connfd是局部变量,没有覆盖问题,但改造时容易踩。
5.5 recv 返回 0 未处理导致死循环
现象:客户端关闭后,服务端 CPU 占用飙升。原因:recv返回 0 表示对端关闭,如果循环条件写成while(1)并在recv返回 0 时继续调用,会一直返回 0,形成忙等。解决:recv返回值小于等于 0 时都要跳出接收循环,等于 0 关闭连接,小于 0 根据errno决定重试还是关闭。实例里while((n = recv(...)) > 0)已经处理了等于 0 的情况,但内层循环退出后没有close(connfd),长跑会泄漏。
6. 进阶改造:把单连接 Demo 改成可复用的收发骨架
6.1 给 recvmsg 加上连接关闭与错误分支
原始recvmsg在内层循环退出后直接回到外层accept,connfd没有关闭。改造如下:
void mysocket::recvmsg() { while (1) { int connfd = accept(m_isocket, (struct sockaddr*)NULL, NULL); if (connfd == -1) { if (errno == EINTR) continue; // 被信号打断,重试 perror("accept error"); break; // 其他错误,退出监听 } char buff[1024]; while (1) { memset(buff, 0, sizeof(buff)); ssize_t n = recv(connfd, buff, sizeof(buff) - 1, 0); if (n > 0) { printf("recv from connfd=%d: %s (len=%zd)\n", connfd, buff, n); } else if (n == 0) { printf("client connfd=%d closed\n", connfd); break; // 对端关闭 } else { if (errno == EINTR) continue; // 中断重试 perror("recv error"); break; } } close(connfd); // 关键:释放已连接描述符 } }改动点有三个:accept失败区分EINTR和其他错误;recv返回值分三种情况处理;内层循环结束后close(connfd)。这样服务端可以连续处理多个客户端,不会泄漏描述符。
6.2 用 SO_REUSEADDR 解决重启时的地址占用
服务端主动关闭后,连接会进入TIME_WAIT状态,持续 2MSL(通常 60 秒左右)。这期间重新启动服务端,bind会报Address already in use。加一行setsockopt即可:
int opt = 1; setsockopt(m_isocket, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));这行要放在bind之前。SO_REUSEADDR允许绑定处于TIME_WAIT的地址,对调试和快速重启很实用。注意它不影响正在监听的端口,两个进程同时监听同一端口仍然会失败。
6.3 验证改造是否生效的观察点
改完之后按这个顺序验证:启动服务端,用ss -tlnp | grep 6666确认监听状态;启动一个客户端,观察服务端打印连接和消息;按 Ctrl+C 关掉客户端,服务端应打印client closed并回到waiting;再启动一个客户端,确认能正常收发。最后用ls /proc/<server_pid>/fd | wc -l看描述符数量,反复连接断开后数量应保持稳定,不持续增长。我一般改完这类骨架都会强制走一遍“连、发、断、再连”的循环,确认没有描述符泄漏才算过。希望这份拆解帮到你。
本文还有配套的精品资源,点击获取