第一次在Linux上写网络程序,很多人第一反应是翻man文档查socket的用法,我也不例外。但真正让我从“会调用API”变成“理解网络”的,反而不是那几个函数签名,而是把脑子里那台“独立主机”的模型拆掉,换成一张协议协作的地图。这篇是Linux网络编程系列的第一篇,目标是带你完成这个思维转换:从单机进程通信的视角,切换到多主机协议协作的视角;把IP、端口、Socket、TCP/IP分层这些概念串成一条线,最后亲手跑通一个最小的TCP程序,并用tcpdump亲眼看到三次握手。不管是还没写过网络代码的新手,还是写过一点但总被奇怪问题卡住的人,这篇都适用。
1. 先从单机思维切换到网络思维
1.1 单机世界的通信方式,以及它们的边界
Linux里进程与进程之间怎么通信?管道、消息队列、共享内存、信号、本地套接字,这些都是经典机制。管道就像一条水管,一端写进去,另一端读出来;共享内存更像是把一张纸贴在墙上,多个进程都能看。这些机制用起来都很顺手,但有一个共同的边界条件:它们都只能在同一台主机上工作。
为什么?因为管道、消息队列、共享内存本质上依赖内核中的公共资源,比如一个文件描述符、一段共享内存区域。两个进程哪怕隔着一堵墙,只要在同一台Linux系统上,就能通过内核搭桥。但如果进程A在笔记本上,进程B在千里之外的服务器上,内核不是同一个,内存不是同一片,管道和共享内存立刻失效。
网络编程要解决的,就是这个跨主机的问题。要让两个运行在不同机器上的进程像在同一台机器上一样交换数据,我们必须设计一套机制:怎么找到对方(寻址)、怎么把数据安全送到(传输控制)、中间经过哪些设备(路由)、数据坏了怎么办(差错检测)。这一整套机制叠加起来,就是我们常说的“协议栈”。所以学网络编程,本质上学的不是某个API,而是这套跨主机通信的规则体系。
1.2 通信的前提是“约定”,协议就是这套约定
想象一下两个人打电话。拨号之前,双方其实默认遵守了一整套流程:主叫方先拨号,被叫方听到铃声后接听,然后一方说“喂”,另一方回应,双方确认能听到声音后才开始讲正事。这套流程看起来稀松平常,但它保证了通信的双方在同一时间、同一频率、同一语言体系下工作。如果没有这套约定,一方用手机、一方用对讲机,或者一方说中文、一方说德语,这个电话根本打不成。
网络协议也一样,它就是通信双方事先约定好的规则。专业一点说,协议包含三要素:语法,规定数据怎么组织,比如字段顺序、格式;语义,规定每个字段代表什么含义,比如某个标志位为1表示数据结束;时序,规定事件发生的顺序,比如先建立连接再传数据,传完数据再断开。举个例子,一个最简单的HTTP GET请求报文长这样:
GET /index.html HTTP/1.1\r\n Host: www.example.com\r\n \r\n这串文本就是一个应用层协议的具体体现。GET是方法,/index.html是路径,HTTP/1.1是版本号,\r\n是行结束符。服务器看到这串文本,就能明白客户端想要什么,并按照同样的规则返回响应。你可能会问:为什么是GET而不是拿?因为协议必须是双方都懂的语言,它不依赖人的自然语言,而是依赖文档规范。互联网上大量协议的标准定义在RFC文档里,任何人都能查阅和实现。
所以请记住这句话:网络编程的核心,是把你对“通信过程的想象”变成双方都能理解的协议。代码只是协议的载体,API只是敲门砖。
2. 分层模型:协议世界的地基
2.1 为什么协议非得分层,不能一个大包?
如果从头设计一套跨主机通信方案,你会发现要处理的问题实在太多。要寻址,要路由,要保证数据不丢,要处理乱序,要流控,还要区分不同应用。把所有逻辑塞进一个协议,结果就是一个巨型怪物:任何一个环节要升级,整个协议都得推翻;任何一个地方出错,排查范围就是海量的。
分层是解决这种复杂度的经典手段。你可以把整个通信过程按职责拆成几个相对独立的层,每一层只干一件明确的事,层与层之间通过标准接口对接。生活中最典型的就是快递系统。商家不关心快递员怎么运输,快递员不关心包裹里装的是什么,仓库工人只负责分拣和贴单。每一层的职责都足够单纯,也可以独立更换——比如商家换了包装盒,快递公司完全不需要知道。
网络协议栈的设计思路正是这样。通过分层,每一层可以独立演化,物理层从以太网换成WiFi、从铜线换成光纤,应用层的HTTP和SSH完全感知不到变化。排错也因此有了明确方向:链路层的问题查MAC地址和交换机,网络层的问题查IP和路由,传输层的问题查端口和连接状态,应用层的问题查报文格式和业务逻辑。
2.2 先记TCP/IP四层,暂缓OSI七层
很多初学者一上来就被“OSI七层模型”劝退。但现实中真正落地的是TCP/IP协议族,它的分层方式和OSI并不完全一致。与其死记七层的抽象概念,不如先建立TCP/IP四层模型这张图:
| 分层 | 核心职责 | 代表协议 | 寻址方式 |
|---|---|---|---|
| 应用层 | 为具体应用提供数据语义 | HTTP、FTP、SSH、DNS | 无固定寻址,靠协议内部字段 |
| 传输层 | 端到端的可靠传输,区分具体应用 | TCP、UDP | 端口号 |
| 网络层 | 跨网络的主机寻址与路由 | IP、ICMP | IP地址 |
| 链路层 | 物理网络内的帧传输 | 以太网、WiFi、VLAN | MAC地址 |
你只需要先记住这个四层模型就够了。应用层处理“数据是什么”,传输层处理“数据交给哪个进程”,网络层处理“数据去哪台机器”,链路层处理“数据怎么在网线上跑”。OSI七层里的会话层、表示层在实际的TCP/IP体系里基本被合并进应用层,初学阶段暂时搞不清楚没关系,等以后分析具体协议时再回头看也不迟。
有个细节值得留意:链路层的以太网帧里有一个VLAN标签字段(802.1Q),用来标记帧属于哪个虚拟局域网。如果你在公司网络里抓包,看到vlan 100之类的信息,这就是链路层在干活,跟我们常说的IP地址不在同一个层面。
2.3 从发送到接收:数据包的封装与解封装
分层模型在数据流动中体现得最直观。假设你在浏览器里敲下http://www.example.com并回车,数据是这样从上往下走的:
应用层把HTTP请求报文交给传输层,传输层的TCP在报文前面加上一个TCP头,这个头里最关键的是源端口和目的端口,比如源端口随机分配一个49152,目的端口是80。接着网络层的IP在TCP头前面再加一个IP头,里面写入源IP和目的IP。最后链路层把整个IP数据报再包成以太网帧,加上目标MAC地址、源MAC地址和帧尾的校验序列,然后把这一串比特通过网线或WiFi发出去。
接收方收到数据后做相反的操作。链路层收帧、校验、去掉帧头帧尾,把IP数据报交给网络层;网络层解析IP头,看到目的IP是本机,就把TCP段上交传输层;传输层检查端口号,发现是80,再把数据交给监听80端口的进程。这个过程叫解封装。
这里有个核心观点:每一层只关心自己的头部信息,不解析上层的内容。就好比快递员只认面单上的收件地址,绝不拆开包裹看你买的是什么东西。这个“越界”概念特别重要,以后你用tcpdump抓包,看到一帧数据里包含以太网头、IP头、TCP头、应用数据四层嵌套,就会瞬间理解封装与解封装在物理世界里的样子。
3. IP、端口与Socket:网络编程的三块基石
3.1 IP地址:找到那台主机
IP地址是网络层寻址的核心,它回答的问题是“数据该发给哪台主机”。类比生活里,IP地址就是门牌号。IPv4地址是一个32位二进制数,习惯上写成四个十进制数,比如192.168.1.100。一个局域网里的每台设备都要有自己的IP地址,否则交换机不知道数据帧该往哪个口送。
在这个基础上还有几个概念要认清。第一个是回环地址127.0.0.1,它代表“本机自己”,不管有没有网卡,它都能通。开发调试时用127.0.0.1最方便,因为数据不会真的跑到物理网络上,纯粹在内核里转一圈就回来了。第二个是私网地址,常见的有10.x.x.x、172.16.x.x到172.31.x.x、192.168.x.x。私网地址只能在内网使用,不能直接上公网。家用路由器给手机、电脑分配的通常都是192.168.x.x这类地址,设备要访问互联网,路由器会做NAT(网络地址转换),把私网地址映射成公网地址。
IPv4的总地址数约43亿,在全球互联网面前早就捉襟见肘,所以有了IPv6,用128位地址彻底解决了数量问题。写网络程序时,函数要优先考虑同时支持IPv4和IPv6,比如用getaddrinfo而不是老旧的gethostbyname。不过初学阶段,先在IPv4上跑通,再去接触IPv6会更容易上手。
3.2 端口号:找到那个进程
有了IP地址,你可以找到一台主机,但主机上同时跑着几十上百个进程。你连上服务器,到底是要把数据交给Web服务器、SSH服务还是数据库?这就轮到端口号登场。端口号是传输层用16位二进制数标识的,范围从0到65535,它回答的问题是“数据该交给这台机器上的哪个进程”。
常见端口要记几个:22是SSH,80是HTTP,443是HTTPS,3306是MySQL,6379是Redis。0到1023范围通常需要管理员权限才能监听,1024到49151是可以自由使用的注册端口,49152到65535一般用于动态分配。客户端发起连接时,系统会自动分配一个临时端口作为源端口,服务端收到的报文里,源IP和源端口就是客户端的身份标识。
一台服务器可以同时监听多个端口,比如同一台机器上开着Nginx(80)、MySQL(3306)、Redis(6379)。而一个端口同一时刻只能被一个监听套接字占用,这是新手一定会踩的坑,后面专门讲。识别一条连接需要五个要素:源IP、源端口、目的IP、目的端口、协议类型(TCP还是UDP),网络领域管这叫五元组。
3.3 Socket:用户程序与内核协议栈之间的门
说完IP和端口,终于来到Linux网络编程的核心对象:Socket。Socket可以理解成“应用程序访问网络协议栈的大门”。你写的程序是用户态进程,协议栈是内核的一部分,两者之间靠Socket这座桥连接。创建Socket后,内核会在协议栈里帮你分配一套收发缓冲区、状态标记和队列,并返回一个文件描述符给你。
这里要强调一个容易混淆的点:Socket不是协议,它是API,是操作系统暴露给用户的网络入口。你通过这个入口调用内核的协议栈能力。Linux里一切皆文件,Socket也不例外的体现为文件描述符,所以你能用read、write来收发数据,也能用close来关闭连接。
创建Socket时最典型的三类参数:
| 地址族 | 类型 | 说明 |
|---|---|---|
| AF_INET | SOCK_STREAM | 基于IPv4的TCP流式套接字 |
| AF_INET | SOCK_DGRAM | 基于IPv4的UDP数据报套接字 |
| AF_UNIX | SOCK_STREAM | 本机进程间通信的本地套接字 |
TCP用流式Socket,因为TCP天然是字节流,有连接、可靠、按序;UDP用数据报Socket,每个报文独立,没有连接概念,适合音视频等允许少量丢失的场景。初学阶段把TCP的Socket流程跑熟,再回头看UDP会轻松很多。记住这句话:Socket是门,IP是门牌,端口是门后面的房间号,协议栈是整栋大楼。
4. 写代码前先练手:用Linux命令触摸协议
4.1 ss与netstat:看一眼当前连接状态
不少人的第一反应是上来写代码,但我建议先学会用命令观察网络状态。Linux系统里最常用的连接查看命令是ss,旧一点的系统用netstat。运行下面的命令:
ss -tan-t只看TCP,-a看所有状态的连接,-n跳过域名解析直接显示IP和端口。输出大致长这样:
State Recv-Q Send-Q Local Address:Port Peer Address:Port LISTEN 0 128 0.0.0.0:22 0.0.0.0:* ESTAB 0 0 192.168.1.100:22 192.168.1.50:52314 TIME_WAIT 0 0 192.168.1.100:22 192.168.1.80:50112这些状态词就是TCP协议的状态机:LISTEN表示这个端口正在被某个进程监听,等待客户端连接;ESTAB(ESTABLISHED)表示一条TCP连接已经建立,双方可以收发数据;TIME_WAIT表示主动关闭连接的一方在等待一段时间,确保迟到的报文在网络中消亡后才能彻底释放端口。初学者看到一堆TIME_WAIT别慌,这是正常现象,系统会在大约60秒后回收这些连接。
如果你想知道某个端口被谁占用了,用这个组合:
sudo ss -tlnp | grep 8080-p参数能显示占用端口的进程PID和名字。排查“端口被占用”这类问题时,这条命令比翻代码管用得多。我见过太多次新手以为bind失败是代码写错了,其实完全是上一个进程没退干净。
4.2 tcpdump:抓到真正的报文看看
理论知识说得再多,都不如亲眼看一次数据包来得通透。tcpdump是Linux下最经典的抓包工具,它把经过网卡的真实报文打印出来。抓TCP连接的过程,比如我们后面要跑的服务器监听8888端口,可以这么抓:
sudo tcpdump -i any -nn tcp port 8888-i any抓所有网卡,-nn不解析域名和端口名,tcp port 8888只抓TCP协议且端口匹配8888的包。运行后发起一次连接,你会看到类似这样的输出:
14:23:01.111111 IP 127.0.0.1.50000 > 127.0.0.1.8888: Flags [S], seq 1000 14:23:01.111222 IP 127.0.0.1.8888 > 127.0.0.1.50000: Flags [S.], seq 2000, ack 1001 14:23:01.111333 IP 127.0.0.1.50000 > 127.0.0.1.8888: Flags [.], ack 2001Flags [S]是SYN报文,表示“我想建立连接”;[S.]是SYN+ACK,表示“同意你的连接请求”;[.]是纯ACK,表示“收到你的确认”。这三个包就是TCP三次握手的完整流程。第一次抓包看不懂没关系,先混个眼熟,知道协议真的是以这么直白的形式在网上跑,就比很多只写CRUD的开发者高一个level了。
抓包也可以保存成文件,交给Wireshark做可视化分析:
sudo tcpdump -i any -w /tmp/capture.pcap tcp port 88884.3 ping与traceroute:验证连通性和路由路径
再介绍两个网络层工具。ping用来验证目标主机通不通,原理是发送ICMP回显请求:
ping -c 4 www.example.com输出会显示每个ICMP报文的往返时间(RTT)和TTL。RTT大说明网络链路慢,TTL值则能间接看出经过了多少跳路由器。如果你刚配置完Linux网络环境,第一件事就应该是ping一下网关,确认本机不在“孤立岛”上。
traceroute更进一步,它打印出数据包从本地到目标主机经过的每一跳路由器地址:
traceroute www.example.com这个工具的原理是利用IP头里的TTL字段,第一次发一个TTL为1的报文,第一跳路由器收到后发现TTL过期,返回一个ICMP超时报文,于是客户端就知道第一跳是谁;然后TTL加1,依次探测第二跳、第三跳。每次上网卡顿,我用ping判断是不是链路问题,用traceroute定位卡在哪一跳,顺序清晰,效率也高。
5. 实战:写一个最小的TCP回声服务
5.1 环境准备与整体思路
接下来的例子需要一台Linux环境,以及gcc编译器。如果没有,最简单的办法是装个虚拟机,或者在Windows上启用WSL。注意,虚拟机默认使用NAT网络模式,外部机器不能直接访问虚拟机的某个端口,这会影响到后面联调,具体在第6章展开。
这个demo的目标极简:一个服务端监听8888端口,接受一个客户端连接;客户端连上后发送一串字符串,服务端收到后原样返回。它不处理并发、不做协议解析、不考虑性能,就是一个能跑通的最小闭环,让你在动手层面感受完整流程:socket、bind、listen、accept、connect、read、write、close。这不仅仅是“跑通就完事”,整个过程中的每个系统调用都会让你确认前面讲的分层模型和协议状态是真实存在的。
5.2 服务端:socket、bind、listen、accept四件套
下面是最简单的TCP服务端代码,保存为server.c:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #define PORT 8888 #define BACKLOG 5 int main() { int server_fd, client_fd; struct sockaddr_in server_addr, client_addr; socklen_t addr_len = sizeof(client_addr); char buffer[1024]; // 1. 创建TCP套接字 server_fd = socket(AF_INET, SOCK_STREAM, 0); if (server_fd < 0) { perror("socket"); exit(EXIT_FAILURE); } // 2. 允许地址复用,避免TIME_WAIT导致bind失败 int opt = 1; setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // 3. 绑定IP和端口 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(PORT); if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { perror("bind"); exit(EXIT_FAILURE); } // 4. 开始监听 if (listen(server_fd, BACKLOG) < 0) { perror("listen"); exit(EXIT_FAILURE); } printf("server listening on port %d\n", PORT); // 5. 接受客户端连接(此处阻塞) client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &addr_len); if (client_fd < 0) { perror("accept"); exit(EXIT_FAILURE); } char *client_ip = inet_ntoa(client_addr.sin_addr); printf("client connected: %s:%d\n", client_ip, ntohs(client_addr.sin_port)); // 6. 接收数据,原样返回 ssize_t len = read(client_fd, buffer, sizeof(buffer) - 1); if (len > 0) { buffer[len] = '\0'; printf("received: %s\n", buffer); const char *msg = "hello from server"; write(client_fd, msg, strlen(msg)); } close(client_fd); close(server_fd); return 0; }逐行看几个关键点。socket(AF_INET, SOCK_STREAM, 0)创建的是IPv4的TCP套接字。AF_INET是地址族,SOCK_STREAM表示流式传输,0让内核自动选TCP协议。bind负责把套接字绑定到一个具体的IP和端口上。为什么需要htons(PORT)?因为端口号在网络传输中要用大端字节序,而x86等小端机器内部是小端,htons把主机字节序转成网络字节序。同样,htonl(INADDR_ANY)是因为INADDR_ANY表示“绑定本机所有可用IP”,也需要转成网络字节序。
listen(server_fd, BACKLOG)让套接字进入监听状态,BACKLOG是未完成连接队列的最大长度。accept是阻塞调用,没有客户端连上来时,进程停在这里不动;有连接到达后它返回一个新的套接字文件描述符client_fd,这个新文件描述符才代表“当前这个客户端连接”,原来的server_fd继续作为监听者等待下一拨连接。这里很容易误解:不是accept返回的fd和监听fd同一个,网络程序里一个fd对应一条连接,连接结束fd就释放。
5.3 客户端:socket、connect、读写
客户端的代码更短一些,保存为client.c:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #define PORT 8888 int main(int argc, char *argv[]) { if (argc != 2) { printf("usage: %s <server_ip>\n", argv[0]); exit(EXIT_FAILURE); } int sock_fd; struct sockaddr_in server_addr; char buffer[1024]; // 1. 创建TCP套接字 sock_fd = socket(AF_INET, SOCK_STREAM, 0); if (sock_fd < 0) { perror("socket"); exit(EXIT_FAILURE); } // 2. 设置服务器地址 memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_port = htons(PORT); // 3. 把点分十进制IP转成二进制 if (inet_pton(AF_INET, argv[1], &server_addr.sin_addr) <= 0) { perror("inet_pton"); exit(EXIT_FAILURE); } // 4. 发起连接,内核帮你完成三次握手 if (connect(sock_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { perror("connect"); exit(EXIT_FAILURE); } // 5. 发送数据 const char *msg = "hello from client"; write(sock_fd, msg, strlen(msg)); // 6. 读取服务器返回的数据 ssize_t len = read(sock_fd, buffer, sizeof(buffer) - 1); if (len > 0) { buffer[len] = '\0'; printf("server says: %s\n", buffer); } close(sock_fd); return 0; }inet_pton把127.0.0.1这种字符串形式的IP转成网络字节序的二进制结构,比老旧的inet_addr更安全。connect成功返回意味着三次握手已经完成,这是TCP非常关键的性质:连接是经过双方确认的,而不是客户端单方面的幻想。connect之后,TCP的序列号、窗口大小都已经协商好,可以直接收发数据。完整流程里,客户端不需要bind,系统会自动分配一个临时端口;不需要listen,因为客户端不是被连接方。
5.4 编译、运行、抓包验证三次握手
编译运行:
gcc server.c -o server gcc client.c -o client ./server & ./client 127.0.0.1服务端会打印client connected: 127.0.0.1:xxxxx,客户端会打印server says: hello from server。read和write在这个例子里用起来像操作文件一样,这也印证了Linux“一切皆文件”的设计哲学。Socket确实是文件描述符,对连接的读写就是对文件描述符的读写。
跑通了只是第一步,建议你按上一节的方法开启抓包,再跑一次程序,亲眼看到SYN、SYN+ACK、ACK三个报文依次出现。这个步骤虽然琐碎,但效果非常好:你会发现TCP握手不是一个抽象概念,而是实实在在出现在网卡上的三个数据包。抓包时如果用-nn参数,IP地址不会被解析成域名,所以127.0.0.1会直接显示,方便对照。
5.5 这个demo暴露出的问题
这个程序能跑,但它极其简陋,局限也很明显:
第一,它一次只能处理一个客户端。accept之后,如果第二个客户端连上来,谁都不理它,因为程序正阻塞在第一个连接的read上。第二,read是阻塞式的,如果客户端一直不发数据,服务端就永远卡在那一行。第三,收发没有消息边界,如果客户端连续发两条消息,服务端可能一次read读完,也可能分三次读完,怎么切分边界需要应用层协议来定义。
这些不是代码写得不好,而是所有TCP网络程序都要面对的基础问题。处理它们要靠多线程、非阻塞IO、IO多路复用(select/poll/epoll)这些技术,这是本系列后面几个篇章的核心内容。现在先把这套流程跑通,肚子里有了一张完整的图,再学那些高级玩法就有参照系了。
6. 新手期最容易踩的四个坑
6.1 bind报错:Address already in use
我敢说每个写TCP服务端的人都遇见过这个错误。表现是第二次启动程序时,bind返回失败,错误信息为Address already in use。通常原因不是端口真的被别的服务占用了,而是上一个程序正常退出了,但TCP连接还在TIME_WAIT状态,系统要等约60秒才能释放端口。
TIME_WAIT产生的原因是被动关闭连接的一方可能还有数据没发完,主动关闭方要留出时间处理迟到的报文。解决这个问题的标准做法就是我们在服务端代码里写的那行setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))。这行代码在bind之前设置,告诉内核:即使有连接处于TIME_WAIT,这个端口也可以立即重用。很多人把它当模板抄,却不理解为什么,这里必须说清楚:没有它,你每次调试都要干等一分钟,有它在,你的开发体验会顺畅很多。
6.2 虚拟机里连不上,别急着怀疑代码
如果你的服务端跑在虚拟机里,客户端从宿主机连接,连不上是最常见的问题。这里面的原因通常不是socket代码,而是虚拟机网络模式。VMware默认的NAT模式下,虚拟机有自己独立的NAT网段,宿主机不能直接访问虚拟机。如果你要从宿主机连虚拟机的8888端口,要么把网络模式改为桥接(bridge),让虚拟机和宿主机处于同一局域网,要么配置NAT端口转发。
判断问题是不是出在网络环境,有一个很省事的办法:在虚拟机内部自己连自己,比如客户端也跑在虚拟机里,连127.0.0.1。如果能通,说明代码没有问题,剩下的就是网络拓扑问题。另外还要检查Linux防火墙,某些发行版默认开启firewalld,可以暂时关闭或者放行端口:
sudo systemctl stop firewalld不需要一上来就怀疑协议栈,先看连通性,再看端口监听,最后看代码,这个排查顺序能帮你省掉大量时间。
6.3 数据收不齐:TCP流式协议没有消息边界
写网络程序最普遍的困惑,就是为什么我的read读出来的数据不完整,或者一次性读到了两条消息。原因是TCP是流式协议,它只保证字节按序到达,但不保证字节的“组合方式”。你可以把TCP想成一根水管,水连续地流,你从水管里接水,接多少取决于你拿杯子的时机,而不是取决于谁往水管里倒了多少水。
应用层如果不好好定义消息边界,就会出现粘包(把两条消息合成一条读)和半包(一条消息被拆成两次读)。解决思路有很多,最常见的是这几种:固定长度消息,每条消息长度一样,读完一个长度再读下一个;分隔符协议,用\r\n或自定义分隔符切分消息;长度前缀,每个消息前面用几个字节声明消息体长度,接收方先读长度,再读对应长度的正文。这已经属于应用层协议设计的范畴,第一篇文章不展开,但你要先有这个意识:光靠TCP自带机制,无法解决业务消息的分界问题。
6.4 单线程阻塞带来的并发瓶颈
最后一个坑,也是从入门到进阶的必经坎:阻塞IO。accept会阻塞,read会阻塞,意味着一个单线程程序同一时间只能服务一个连接。很多新手在这里会把程序改成多线程,为每个连接开一个线程,但线程多了以后又有资源竞争和上下文切换开销,所以业界主流做法是用IO多路复用(select/poll/epoll)配合非阻塞IO,用少量线程管理大量连接。
第一篇文章不谈实现细节,只给你一个宏观认知:网络编程的下半场,是从“能通”走向“能扛”的过程。当你的程序从单连接进化到支持成千上万并发连接时,你对epoll、事件驱动、用户态协议栈这些词的理解就会完全不一样。现在先把单连接的demo吃透,后面的路一步一步走。
我当年第一次把这个demo跑通时,兴冲冲给一台远程服务器发消息,结果客户端一直卡住不动,最后发现是对端根本没启动服务,而不是代码问题。从那以后我养成了一个习惯:先抓包、再猜原因。这个习惯一直用到现在。如果你也是刚迈进Linux网络编程的门槛,建议把tcpdump这类工具用熟,让真实的数据包成为你排查问题的第一依据。等脑子里有了这张协议运行的全局图,再看后续的并发处理、非阻塞IO、epoll,一切都会顺理成章。