简介:这是一份面向Linux网络编程初学者与进阶开发者的Socket编程学习资料,围绕进程间通信、Socket接口原理与TCP连接管理展开,帮助读者理解跨主机进程通信的标识机制与套接字操作流程。内容涵盖socket()、bind()、listen()、connect()、accept()、read()/write()、close()等核心函数用法,并详解TCP三次握手建立连接与四次挥手释放连接的过程,最后附有实践示例供动手验证。资源包为1个docx文档,约77KB,以图文笔记形式组织,便于按章节顺序阅读与查阅。目前已有241人学习,适合希望系统掌握Linux Socket编程、为开发Web服务器或聊天应用打基础的读者参考。
1. 从一份“老派”Socket 讲义说起:为什么今天还值得翻出来跑一遍
很多人第一次写网络程序,是从socket()开始的,然后被bind、listen、accept这一串函数绕得云里雾里。这份《Linux Socket编程(附实例)》讲义,就是那种典型的“老派但扎实”的资料:它不急着教你写高并发框架,而是先把网络进程通信的标识问题讲清楚——IP 定位主机、协议加端口定位进程,三元组一摆,Socket 的定位就出来了。接着它把socket()、bind()、listen()、connect()、accept()、read/write、close逐个拆开,还配了 TCP 三次握手和四次挥手的函数级时序说明。最实在的是最后那个可编译的 C++ 小例子:一个mysocket类封装服务端和客户端,服务端监听 6666 端口,客户端连上去发消息。如果你正在学网络编程、准备面试、或者想带新人理解 TCP 连接到底发生在哪个函数调用里,这份资料能帮你把“玄学”变成可调试的代码。下面我就按自己复现的路径,把它拆成能直接抄作业的步骤。
2. 把 Socket 基本操作拆成可编译的最小闭环:从 socket() 到 close()
2.1 先理解“一切皆文件”在 Socket 上的映射
这份资料反复强调一个点:Socket 是 Unix“一切皆文件”哲学在网络层的实现。普通文件用open拿到文件描述符,然后read/write,最后close;Socket 用socket()拿到 socket 描述符,然后send/recv或read/write,最后close。这个类比不是修辞,它直接决定了你排错时的思路——文件描述符会耗尽、会泄漏,Socket 也一样。资料里给出的函数原型是:
int socket(int domain, int type, int protocol); int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); int listen(int sockfd, int backlog); int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);参数含义资料里讲得很细,我按自己的理解再压一遍:domain选AF_INET就是 IPv4,type选SOCK_STREAM就是 TCP,protocol填 0 让系统根据 type 自动选默认协议。这里有个容易翻车的点:SOCK_STREAM不能和IPPROTO_UDP组合,类型和协议必须匹配。bind的第二个参数是struct sockaddr *,实际传的是sockaddr_in,需要强制转换。listen的backlog是排队连接数上限,资料里例子用了 10,生产环境通常要结合somaxconn调整。accept返回的是一个全新的描述符,代表与某个客户端的连接,而原来的监听描述符继续留着接新连接——这个区分是后面写并发服务器的前提。
2.2 服务端和客户端的调用顺序不能乱
资料里把服务端和客户端的流程画得很清楚,我把它整理成可对照的步骤表:
| 角色 | 调用顺序 | 关键说明 |
|---|---|---|
| 服务端 | socket → bind → listen → accept → recv/send → close | bind 绑定知名端口,listen 转被动,accept 阻塞等连接 |
| 客户端 | socket → connect → send/recv → close | 通常不 bind,connect 时内核随机分配本地端口 |
这个顺序不是建议,是硬约束。服务端如果先listen再bind,会直接报错;客户端如果先connect再bind,虽然语法上可能过,但绑定端口后 connect 的行为会变得不直观。资料里特别指出:客户端不调用bind,connect时由系统自动分配端口和 IP 组合。我一般会在客户端也显式bind一个端口 0,让内核选,这样调试时能看到本地端口,但这不是必须的。
2.3 字节序转换:那个引发过“血案”的 htons
资料里用了一段很重的语气讲字节序:“由于这个问题曾引发过血案!”主机字节序分大端小端,网络字节序统一是大端。bind和connect之前,端口必须用htons()转成网络字节序,IP 地址用inet_pton()或htonl()处理。我见过太多人直接写servaddr.sin_port = 6666;,在小端机器上跑起来端口就变成了 0x1A0A 之类的怪值,然后bind报EADDRINUSE或者连不上。正确写法:
servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(6666);INADDR_ANY表示绑定本机所有网卡地址,htonl把它转成网络字节序。客户端侧用inet_pton(AF_INET, "192.168.1.18", &servaddr.sin_addr)把点分十进制转成二进制,这个函数比老的inet_addr更安全,能处理错误返回值。
2.4 把资料里的 mysocket 类跑起来
资料最后给了一个完整的 C++ 例子,文件结构是mysocket.h、mysocket.cpp、server.cpp、client.cpp。我把它整理成可直接编译的版本,关键部分保留原逻辑,补上必要的头文件和错误处理:
// mysocket.h #ifndef _MYSOCKET_H #define _MYSOCKET_H #include <sys/socket.h> class mysocket { public: mysocket(); ~mysocket(); bool createclient(int domain, int soctype, int protype, int port); bool createserver(int domain, int soctype, int protype, int port); void recvmsg(); void sedmsg(); private: int m_idomain; int m_isoctype; int m_iprotype; int m_isocket; }; #endif// mysocket.cpp 关键片段 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; }// server.cpp #include "mysocket.h" int main() { mysocket server; if (server.createserver(AF_INET, SOCK_STREAM, 0, 6666)) { server.recvmsg(); } return 1; }// client.cpp #include "mysocket.h" int main() { mysocket client; if (client.createclient(AF_INET, SOCK_STREAM, 0, 6666)) { client.sedmsg(); } return 1; }编译命令:
g++ -o server server.cpp mysocket.cpp g++ -o client client.cpp mysocket.cpp先跑./server,再跑./client。服务端会打印======waiting for client's request======,客户端连上后循环发送msg = 0.、msg = 1.这样的消息,服务端recvmsg里打印收到的内容和长度。这里有个细节:资料里recvmsg的循环写法是while((n = recv(connfd, buff, 1023, 0)) > 0),但循环体内先memset再buff[n] = '\0',顺序上memset把刚收到的数据清掉了,实际打印的是空。我复现时把memset挪到recv之前,或者干脆用n控制打印长度,才能看到正确消息。这个坑后面还会展开。
3. 三次握手和四次挥手到底发生在哪个函数里:用 strace 验证
3.1 connect 和 accept 的返回时机
资料里有一句总结很关键:“客户端的 connect 在三次握手的第二次返回,而服务器端的 accept 在三次握手的第三次返回。”这句话如果只背结论很容易忘,我建议用strace跟一遍。服务端跑起来后,用strace -f -e trace=network ./server观察,客户端connect时你会看到sendto发出 SYN,然后recvfrom收到 SYN+ACK,connect返回。服务端这边,accept在收到第三次 ACK 后才返回。这个时序解释了为什么connect返回成功不代表服务器应用层已经accept——连接已经在内核里建立了,但服务器进程可能还没取走。
3.2 四次挥手和 close 的引用计数
资料里讲close时提到:“close 操作只是使相应 socket 描述字的引用计数-1,只有当引用计数为 0 的时候,才会触发 TCP 客户端向服务器发送终止连接请求。”这个点很多人忽略。如果你用fork或dup复制了 socket 描述符,只close一次不会发 FIN。四次挥手的流程是:主动关闭方发 FIN,被动方回 ACK,被动方应用层读到 EOF 后调用close发 FIN,主动方回 ACK。资料里的图 2 把这个过程画得很清楚。实际调试时,如果发现连接没有正常关闭,先检查是不是有地方dup了描述符没关干净。
3.3 用 tcpdump 看握手包
想更直观,可以在回环口抓包:
tcpdump -i lo port 6666 -nn -S-S让序列号显示绝对值,方便对照。你会看到Flags [S]、Flags [S.]、Flags [.]三个包,对应三次握手。关闭时看到Flags [F.]、Flags [.]、Flags [F.]、Flags [.]四个包。这个验证方法比看图更可靠,因为你能确认自己的代码真的触发了这些包。
4. 避坑与排查:那些年我在 Socket 上翻过的车
4.1 bind 报 Address already in use
现象:服务端重启时bind返回 -1,errno是EADDRINUSE。原因:TCP 连接关闭后,主动关闭方会进入TIME_WAIT状态,默认持续 2MSL(通常 60 秒),期间端口不能被新 socket 绑定。如果服务端是主动关闭方,重启就会撞上。解决:在bind之前设置SO_REUSEADDR:
int opt = 1; setsockopt(m_isocket, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));这允许绑定处于TIME_WAIT的端口。注意SO_REUSEPORT是另一个选项,用于多进程负载均衡,不要混用。
4.2 accept 返回的 connfd 和监听 fd 搞混
现象:服务端只能处理一个客户端,第二个客户端连上来没反应。原因:把accept返回的connfd当成监听描述符继续accept,或者recv时用了m_isocket而不是connfd。解决:记住m_isocket只负责accept,一旦拿到connfd,所有读写都用connfd。资料里recvmsg函数用的是connfd,这是对的。如果要支持多客户端,需要把connfd交给线程或epoll,不能在一个循环里串行处理。
4.3 recv 返回 0 被当成正常数据
现象:客户端关闭后,服务端recv返回 0,程序继续循环,打印空消息。原因:recv返回 0 表示对端关闭了连接(收到 FIN),不是读到空数据。资料里while((n = recv(...)) > 0)在 n=0 时会退出循环,这是对的。但如果在循环体内先memset再判断,可能把 0 当成有效长度。解决:recv返回值小于等于 0 都要处理:等于 0 关闭连接,小于 0 检查errno是否为EINTR,是就重试,否则报错。
4.4 客户端 connect 的 IP 写死导致换环境就挂
现象:资料例子里的客户端写死了192.168.1.18,换台机器或换网段就连不上。原因:硬编码 IP 只适合本机回环或固定实验环境。解决:把 IP 做成命令行参数或配置文件读取。本机测试直接用127.0.0.1。如果服务端和客户端不在同一台机器,确保防火墙放行 6666 端口,并且服务端绑定的是INADDR_ANY而不是127.0.0.1。
4.5 忘记处理 EINTR 导致程序莫名退出
现象:accept或recv被信号中断,返回 -1,errno是EINTR,程序直接报错退出。原因:慢系统调用在收到信号时会中断,这是 POSIX 行为。解决:对accept、recv、send的 -1 返回值判断errno == EINTR,如果是就继续循环。资料里accept的错误处理用了continue,但没有区分EINTR和其他错误,严格来说应该分开处理。
5. 从单连接例子到能用的服务端:几个我常加的改进
资料里的例子是单连接、阻塞式的,适合理解流程,但直接拿去用会很快遇到瓶颈。我一般会在它的基础上加三样东西。第一,把accept放到循环里,每个connfd开一个std::thread或丢进线程池,避免一个客户端占住整个服务端。第二,给recv加超时,用setsockopt设置SO_RCVTIMEO,防止客户端连上不发数据把服务端卡死:
struct timeval tv; tv.tv_sec = 5; tv.tv_usec = 0; setsockopt(connfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));第三,把send的返回值检查做完整。资料里sedmsg对send失败用了continue,但如果是EPIPE(对端关闭),继续发只会一直报错。正确做法是EPIPE时跳出循环并close。另外,send返回小于请求长度时,剩余部分需要继续发,不能假设一次send全部发完。这些改进不改变资料的核心逻辑,但能让代码从“能跑”变成“敢用”。我每次把这类例子交给新人,都会让他们先跑通原版,再自己加线程和超时,最后用tcpdump验证关闭流程。从那以后我每次写 Socket 代码,都强制走一遍strace看握手和挥手,确认没有描述符泄漏。希望帮到你。
本文还有配套的精品资源,点击获取