ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux Socket编程实战:从核心API到避坑指南

Linux Socket编程实战:从核心API到避坑指南

简介:这是一份面向Linux网络编程初学者与进阶开发者的Socket编程学习资料,围绕进程间通信、Socket概念、基本操作函数以及TCP连接建立与释放过程展开,帮助读者理解跨主机进程通信的标识机制与接口设计。内容涵盖socket()、bind()、listen()、connect()、accept()、read()、write()、close()等核心函数,并详解TCP三次握手建立连接与四次挥手释放连接流程,最后附有实践示例与思考问题,便于边学边练。资源包为1个docx文档,约77KB,结构紧凑,适合作为系统梳理Socket原理与接口用法的参考笔记。目前已有241人学习,适合希望夯实网络编程基础、准备面试或动手实现服务端与客户端通信的开发者阅读。

1. 从一份“老派”Socket 实例说起:为什么它仍是网络编程的活地图

很多人第一次接触网络编程,是从socket()、bind()、listen()这几个函数开始的。这份名为《Linux Socket编程(附实例)》的资源,就是围绕这套最经典的 C 语言 Socket API 展开的。它不讲花哨的框架,而是把 TCP 连接从建立到释放的全过程拆开揉碎,最后用一个可编译运行的服务端/客户端实例收尾。如果你正在学网络编程、准备面试、或者需要手写一个轻量级 TCP 服务,这份代码和讲解能帮你把“三次握手发生在哪个函数里”这种玄学问题彻底搞清楚。它适合刚接触 Linux 网络编程的新手,也适合想回头夯实基础的熟手。

2. 核心模块拆解:从 socket() 到 close() 的完整链路

2.1 网络进程标识与 socket() 的选型逻辑

网络进程通信的第一步是唯一标识一个进程。本地可以用 PID,但跨主机就得靠三元组:IP 地址、协议、端口。TCP/IP 协议族把这套标识机制标准化,而 Socket 就是应用层访问这套机制的接口。资源里反复强调“一切皆文件”的 Unix 哲学,Socket 本质上是一种特殊的文件描述符,后续的读写、关闭都沿用文件操作的模式。

创建 Socket 的函数原型是:

int socket(int domain, int type, int protocol);

三个参数决定了你拿到的是什么类型的“文件”:

  • domain:协议族,常用AF_INET(IPv4)、AF_INET6(IPv6)、AF_UNIX(本地域)。选AF_INET就意味着后面绑定的地址必须是 IPv4 地址加端口。
  • type:Socket 类型,SOCK_STREAM对应 TCP,SOCK_DGRAM对应 UDP,SOCK_RAW用于原始套接字。资源里特别提醒,SOCK_STREAM不能和IPPROTO_UDP组合,类型和协议必须匹配。
  • protocol:通常传 0,让系统根据 type 自动选择默认协议。只有当你需要特定协议时才显式指定,比如IPPROTO_TCP。

调用成功后返回一个 socket 描述符,但它此时还没有具体地址。服务端需要后续bind()绑定,客户端可以交给系统在connect()时自动分配。这个细节在写服务端程序时很关键:如果你不bind()就直接listen(),系统会随机分配端口,客户端根本不知道连哪里。

2.2 bind() 与字节序:一个容易翻车的地址绑定环节

bind()的作用是把一个地址族中的特定地址赋给 socket。函数原型:

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

sockfd是socket()返回的描述符,addr指向协议地址结构,addrlen是地址长度。对于 IPv4,地址结构是struct sockaddr_in,里面包含sin_family、sin_port、sin_addr三个关键字段。

资源里用血泪经验强调了一个点:主机字节序和网络字节序不是一回事。x86 机器是小端,而 TCP/IP 协议要求大端传输。所以绑定端口时必须用htons()把主机字节序转成网络字节序,绑定 IP 时用htonl()。如果你直接servaddr.sin_port = 6666;,在 x86 上跑起来端口就变成了 0x1A0A,客户端连不上,排查半天可能都想不到是字节序的问题。

服务端通常绑定INADDR_ANY,表示监听本机所有网卡地址:

servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(port);

客户端一般不需要手动bind(),系统会在connect()时自动分配一个临时端口和本机 IP。这是服务端和客户端在地址处理上的核心区别。

2.3 listen()、accept() 与连接队列:服务端如何同时应对多个客户端

服务端在bind()之后调用listen()把主动 socket 转为被动监听状态:

int listen(int sockfd, int backlog);

backlog是连接队列的最大长度。资源里用的是 10,意味着内核最多允许 10 个已完成三次握手但还没被accept()取走的连接排队。如果队列满了,新的连接请求会被拒绝或忽略。实际项目中这个值要根据并发量调整,常见做法是设成SOMAXCONN让系统决定。

accept()从已完成连接队列中取出一个连接:

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

第一个参数是监听 socket,返回的是一个全新的已连接 socket 描述符。资源里特别指出:监听 socket 在整个服务生命周期内一直存在,而每个客户端连接对应一个独立的已连接 socket,服务完成后要单独close()。很多新手会混淆这两个描述符,试图用监听 socket 去recv()数据,结果当然读不到东西。

2.4 三次握手与四次挥手:函数调用背后的协议动作

资源用两段清晰的描述把 Socket 函数和 TCP 状态机对应了起来。

三次握手建立连接:

  • 客户端调用connect(),发送 SYN J,connect()进入阻塞。
  • 服务端收到 SYN J,accept()被唤醒准备接收,向客户端回 SYN K 和 ACK J+1。
  • 客户端收到 SYN K 和 ACK J+1 后,connect()返回,并发送 ACK K+1。
  • 服务端收到 ACK K+1,accept()返回,连接建立完成。

关键结论:客户端的connect()在第二次握手后返回,服务端的accept()在第三次握手后返回。这个时间差解释了很多并发场景下的行为差异。

四次挥手释放连接:

  • 主动关闭方调用close(),发送 FIN M。
  • 被动关闭方收到 FIN M,返回 ACK M+1,此时它的应用进程会收到文件结束符。
  • 被动关闭方处理完数据后调用close(),发送 FIN N。
  • 主动关闭方收到 FIN N,返回 ACK N+1,连接彻底关闭。

资源里提到close()只是把描述符引用计数减一,只有引用计数归零才会真正触发 FIN。如果多个进程共享同一个 socket,这一点要特别注意。

3. 动手跑起来:编译、运行与验证完整实例

3.1 文件结构与类封装设计

实例代码把服务端和客户端的公共逻辑封装成了一个mysocket类,文件结构如下:

mysocket/ ├── mysocket.h # 类声明 ├── mysocket.cpp # 类实现 ├── server.cpp # 服务端入口 └── client.cpp # 客户端入口

mysocket类对外暴露四个核心方法:createserver()、createclient()、recvmsg()、sedmsg()。私有成员保存了协议族、Socket 类型、协议号和 socket 描述符。这种封装方式虽然简单,但把创建、绑定、监听、收发的基本流程都串起来了,适合作为二次开发的起点。

3.2 服务端创建流程与关键代码

createserver()方法完成了服务端从创建到监听的完整流程:

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) { printf("listen socket error\n"); return false; } printf("======waiting for client's request======\n"); return true; }

这段代码的逻辑很直白:创建 socket、填充地址结构、绑定、监听。注意memset清零地址结构这一步不能省,否则sin_zero字段可能残留垃圾数据。htonl(INADDR_ANY)和htons(port)是字节序转换的标准写法,任何一本网络编程教材都会反复强调。

3.3 客户端连接与消息发送

createclient()方法负责客户端连接:

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); 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()把点分十进制 IP 转成网络字节序的二进制地址。资源里标注了“红底色 IP 需要修改为 server 的 IP”,意思是如果你在另一台机器上跑客户端,必须把192.168.1.18改成服务端的实际 IP。本机测试可以直接用127.0.0.1。

3.4 编译运行与结果验证

编译命令很简单,两个可执行文件分别编译:

g++ -o server server.cpp mysocket.cpp g++ -o client client.cpp mysocket.cpp

先启动服务端:

./server

看到======waiting for client's request======说明监听已经就绪。再开一个终端启动客户端:

./client

客户端会持续发送msg = 0.、msg = 1.这样的消息,服务端打印收到的内容和字节数。这个循环发送的设计方便你观察 TCP 流的连续性,也方便用Ctrl+C中断后观察四次挥手的过程。

提示:如果服务端和客户端不在同一台机器,记得检查防火墙是否放行了 6666 端口,以及客户端代码里的 IP 是否指向了正确的服务端地址。

4. 避坑与排查:Socket 编程中那些让人抓狂的瞬间

4.1 端口被占用导致 bind 失败

现象:服务端启动时报bind socket error,程序直接退出。

原因:上一次运行的服务端进程没有完全退出,端口仍处于TIME_WAIT状态,或者被其他程序占用。

解决:用netstat -tlnp | grep 6666查看端口占用情况。如果是TIME_WAIT,可以在bind()之前设置SO_REUSEADDR:

int opt = 1; setsockopt(m_isocket, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

这行代码要加在bind()之前、socket()之后。生产环境几乎都会加上这个选项。

4.2 客户端 connect 返回连接被拒绝

现象:客户端报connect to server error,errno 是ECONNREFUSED。

原因:服务端没有启动,或者客户端填的 IP/端口不对,或者中间有防火墙拦截。

解决:先确认服务端已经打印出等待连接的提示。再用telnet 服务端IP 6666测试端口是否可达。如果 telnet 不通,检查服务端绑定的是不是INADDR_ANY,以及防火墙规则。

4.3 recv 返回 0 导致死循环

现象:服务端在客户端关闭后仍然不断打印空消息,CPU 占用飙升。

原因:recv()返回 0 表示对端已经关闭连接,但示例代码的循环条件写的是while((n = recv(...)) > 0),返回 0 时循环退出。如果改成while(1)且不判断返回值,就会出问题。

解决:始终检查recv()的返回值。返回 0 时主动close(connfd)并跳出循环,返回 -1 时根据 errno 决定是重试还是关闭。EINTR可以重试,ECONNRESET说明连接被重置,应该直接关闭。

4.4 发送大块数据时 write 返回不完整

现象:客户端发送 1MB 数据,服务端只收到了一部分。

原因:TCP 是流式协议,send()返回的字节数可能小于请求发送的字节数。内核发送缓冲区满了就会这样。

解决:写一个循环发送函数,确保所有数据都写出去:

ssize_t send_all(int fd, const void *buf, size_t len) { size_t sent = 0; const char *p = (const char *)buf; while (sent < len) { ssize_t n = send(fd, p + sent, len - sent, 0); if (n <= 0) { if (errno == EINTR) continue; return -1; } sent += n; } return sent; }

这个函数在发送文件、图片等大块数据时几乎是必备的。

4.5 忘记字节序转换导致端口错乱

现象:服务端绑定 6666 端口,客户端连 6666 却连不上,用netstat看发现服务端实际监听的是一个奇怪的端口号。

原因:绑定端口时没有用htons(),在小端机器上端口值被解释成了另一个数。

解决:所有涉及端口和 IP 地址的赋值,一律用htons()、htonl()转换。接收端用ntohs()、ntohl()转回来。这是网络编程的铁律,没有例外。

5. 二次开发改造:从示例到可用服务的几步

这份实例代码最大的价值在于它把 TCP 服务端和客户端的最小闭环讲清楚了。但直接拿它做项目肯定不够,下面是我在实际改造中会做的几件事。

第一步:把单连接改成多连接。示例的recvmsg()只accept()一次,处理完一个客户端就退出了。实际服务端需要循环accept(),每个连接可以开一个线程或丢进epoll事件循环。最简单的改法是:

while (1) { int connfd = accept(m_isocket, NULL, NULL); if (connfd < 0) continue; std::thread([this, connfd]() { // 处理这个连接 char buff[1024]; while (1) { int n = recv(connfd, buff, sizeof(buff), 0); if (n <= 0) break; // 处理数据 } close(connfd); }).detach(); }

第二步:加上错误处理和日志。示例里大量使用printf,生产环境应该换成日志库,并且把 errno 对应的错误字符串打出来。strerror(errno)是最基本的排查手段,不要只打印“error”两个字。

第三步:处理 SIGPIPE 信号。向已关闭的连接写数据会触发 SIGPIPE,默认行为是进程直接退出。在main()开头加一行signal(SIGPIPE, SIG_IGN);就能避免这个坑。然后通过send()返回的EPIPE错误来判断连接是否已断开。

第四步:设置超时。accept()、connect()、recv()都可能永久阻塞。可以用setsockopt设置SO_RCVTIMEO和SO_SNDTIMEO,或者用select/poll做超时控制。我一般会给accept()设置一个较短的超时,方便程序在收到退出信号时能及时响应。

第五步:验证改造效果。改完之后用nc或telnet手动测试基本连通性,再用ab或wrk做压力测试。观察netstat -s里的重传和错误统计,确认没有异常。如果并发上来后出现大量TIME_WAIT,考虑调整内核参数或改用长连接。

注意:二次开发时不要一上来就上框架。先把这份示例的每个函数调用搞清楚,知道每个参数为什么这么填,再逐步替换成更高效的实现。跳过基础直接抄框架代码,出了问题连排查方向都没有。

从那以后我每次写 Socket 程序,都会先把SO_REUSEADDR、SIGPIPE忽略、字节序转换这三件事检查一遍,再开始写业务逻辑。希望这份实例和上面的改造思路能帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进