计算机网络概念入门资料,从入门到进阶,依次如下:
一个视频讲清楚家庭网络通信流程,折腾软路由前必看的计算机网络通识教程 | Youtube
图解计算机网络| 小林 coding | Github 开源版本
Linux 网络编程
网络编程的核心——Socket
无论是 UDP 还是 TCP,在 Linux 中都是通过“Socket(套接字)”这一统一的接口来实现的。可以把 Socket 理解为一个“通信端点”,我们的程序通过读写这个“文件”来收发网络数据。
本笔记将围绕 Socket 展开,分为两大核心部分:
- UDP 编程:学习如何使用数据报套接字(
SOCK_DGRAM),实现简单、高效但不可靠的通信 - TCP 编程:学习如何使用流式套接字(
SOCK_STREAM),实现复杂、可靠、面向连接的通信
我会先从各自的协议特性讲起,然后深入学习核心 API 的使用,最后探讨各自的高级应用场景和服务器模型
UDP 协议
一、传输层协议与 UDP 基础
1. UDP 协议的核心特点
UDP (User Datagram Protocol) 是传输层的一个核心协议,其设计哲学是简单、高效。根据其标准文档 RFC 768,UDP 的主要特点可以总结如下:
- 无连接 (Connectionless):发送数据前,双方不需要像打电话一样先“建立连接”。发送端直接将数据打包发出,接收端直接接收。
- 不可靠 (Unreliable):UDP 不保证数据包一定能送达,也不保证数据包的顺序,更不会在数据包丢失后进行重传。所有可靠性保障都需要应用层自己实现。
- 面向数据报 (Datagram-Oriented):应用层交下来多大的数据块,UDP 就直接打包成一个数据报发送,它不会对数据进行拆分或合并。
- 高效率、低延迟:由于没有建立连接、确认、重传等复杂的机制,UDP 的头部开销小,处理速度快,延迟低。
一句话总结:UDP 提供的是一种“尽力而为”的、高效的、简单的信息传送服务。
2. UDP 协议的报头结构
UDP 的报头非常简单,仅有 8 个字节,这也是其高效的原因之一
- 源端口号 (16 位):发送方进程的端口号。可选,若不使用则为 0。
- 目标端口号 (16 位):接收方进程的端口号。必需。
- 包总长度 (16 位):UDP 报头 + UDP 数据的总长度(字节)。最大值为 65535 字节。
- 实际数据最大值:65535 - 8 (UDP 头) - 20 (IP 头) = 65507 字节。
- 实践建议:为避免在网络层被分片(分片会增加丢包风险),UDP 数据包的大小最好控制在 MTU (通常为 1500 字节) 以内,即数据大小不超过 1472 字节。
- 校验和 (16 位):用于检查数据在传输过程中是否出错。可选,若不使用则为 0。
二、UDP 核心 Socket API
在 Linux 中,网络通信是通过 Socket (套接字) 文件来实现的。“一切皆文件”的思想在这里也适用。
UDP 通信基本流程:
- 服务器 (接收端):
socket()->bind()->recvfrom()->close() - 客户端 (发送端):
socket()->sendto()->close()
1. socket() - 创建套接字
man 2 socket 查看接口函数。
1 |
|
domain: 协议族。对于 IPv4 网络,使用AF_INET。type: 套接字类型。SOCK_DGRAM: 用于 UDP 协议 (数据报套接字)。SOCK_STREAM: 用于 TCP 协议 (流式套接字)。
protocol: 具体协议。通常设为0,让系统自动选择。- 返回值:成功返回一个文件描述符 (sockfd),失败返回 -1。
创建 UDP 套接字示例:
1 | int sockfd = socket(AF_INET, SOCK_DGRAM, 0); |
2. bind() - 绑定地址和端口
man 2 bind 查看接口函数。
1 | int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); |
- 作用:将一个套接字与一个具体的 IP 地址和端口号绑定起来。这通常是服务器端在接收数据前必须做的
sockfd:socket()返回的文件描述符addr: 指向一个struct sockaddr结构体的指针,但实际使用时我们通常填充一个struct sockaddr_in结构体,然后进行强制类型转换,这样方便管理 ipv4 数据addrlen:addr结构体的长度。
struct sockaddr_in 结构体
1 |
|
核心概念:网络字节序 (Big Endian)
不同的计算机体系结构存储多字节数据的方式可能不同(大端 vs. 小端)。为了在网络中统一标准,所有网络协议都规定使用大端序。因此,在填充sockaddr_in结构体时,必须将主机字节序的端口和地址转换为网络字节序。
htons(): Host to Network Short (16 位,用于端口号)。htonl(): Host to Network Long (32 位,用于 IP 地址)。ntohs(),ntohl(): Network to Host,反向转换。inet_addr(): 将点分十进制的 IP 地址字符串转换为网络字节序的 32 位整数。
setsockopt()设置端口复用
在网络程序开发,尤其是在频繁重启调试时,你几乎一定会遇到 bind failed: Address already in use 这个错误。
问题根源:当你关闭一个网络程序时,操作系统为了确保网络中残余的数据包能被正确处理,会使该程序占用的端口进入一个名为
TIME_WAIT的状态,并持续一小段时间。在这段时间内,该端口被视为“仍在使用中”,任何新程序都无法立即绑定它。解决方案:使用
setsockopt()函数为套接字设置SO_REUSEADDR(地址复用) 选项。这个选项向操作系统声明:“我允许将此套接字绑定到一个正在TIME_WAIT状态的地址上”。
setsockopt() 函数
1 |
|
- 作用:设置套接字的各种选项,以改变其默认行为。
level: 选项所属的协议层。SOL_SOCKET表示套接字本身。optname: 选项名称,例如SO_REUSEADDR。optval: 指向一个变量的指针,该变量的值就是选项要设置的值。
代码实现
在 socket() 创建套接字之后、bind() 绑定地址之前,加入以下代码:
1 | // 在 socket() 之后,bind() 之前 |
重要提示:
SO_REUSEADDR选项主要用于解决TIME_WAIT状态导致无法立即重启程序的问题。它不能让两个程序在同一时刻都成功bind并监听同一个端口(除非在特定的组播场景下)。如果错误持续存在,请检查是否已有另一个程序实例正在后台运行。
3. sendto() & recvfrom() - 收发数据
man 2 sendto 和 man 2 recvfrom 查看接口函数
1 | // 发送数据 |
sendto()需要指定目标地址dest_addr。recvfrom()会阻塞程序,直到收到数据,并能获取到源地址src_addr。
示例:简单的客户端/服务器
服务器 (接收端)
udp_server.c1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int main(void) {
// 1. 创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// 2. 绑定地址和端口
struct sockaddr_in my_addr;
my_addr.sin_family = AF_INET;
my_addr.sin_port = htons(8888); // 绑定8888端口
my_addr.sin_addr.s_addr = inet_addr("192.168.59.198"); // 绑定本机IP
bind(sockfd, (struct sockaddr *)&my_addr, sizeof(my_addr));
// 3. 接收数据
char buf[128] = {0};
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr *)&client_addr, &len);
printf("收到来自 %s 的消息: %s\n", inet_ntoa(client_addr.sin_addr), buf);
// 4. 关闭套接字
close(sockfd);
return 0;
}客户端 (发送端)
udp_client.c1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main(void) {
// 1. 创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// 2. 准备目标地址并发送数据
struct sockaddr_in dest_addr;
dest_addr.sin_family = AF_INET;
dest_addr.sin_port = htons(8888);
dest_addr.sin_addr.s_addr = inet_addr("192.168.59.198");
char *msg = "Hello, UDP Server!";
sendto(sockfd, msg, strlen(msg), 0, (struct sockaddr *)&dest_addr, sizeof(dest_addr));
// 3. 关闭套接字
close(sockfd);
return 0;
}
示例:使用线程实现收发一体
1 |
|
三、UDP 的广播与组播
1. UDP 广播 (Broadcast)
- 概念:向局域网内的所有主机发送数据包。
- 广播地址:一个特殊的 IP 地址,通常是网络号不变,主机号全为 1。例如,对于
192.168.5.0/24网段,广播地址是192.168.5.255。 - 实现:
- 发送方:必须使用
setsockopt()函数为套接字开启广播权限。 - 接收方:
bind()时绑定的 IP 地址应为INADDR_ANY,表示接收发送到本机任意网卡的数据包。
- 发送方:必须使用
setsockopt() - 设置套接字
man 2 setsockopt 查看接口函数。
1 | int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen); |
例如设置开启广播权限:
1
2int on = 1;
setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on));
2. UDP 组播/多播 (Multicast)
概念:向一个特定“小组”内的所有成员发送数据包,只有加入了该组的主机才能收到。这比广播更高效,因为它只影响感兴趣的主机。
组播地址:D 类 IP 地址,范围从
224.0.0.0到239.255.255.255。实现:
- 发送方:与广播类似,需要开启广播权限,并向一个组播地址发送数据。
- 接收方:必须使用
setsockopt()加入一个或多个组播组。
加入组播组:
1
2
3
4struct ip_mreq mreq;
mreq.imr_multiaddr.s_addr = inet_addr("224.0.0.10"); // 组播组地址
mreq.imr_interface.s_addr = htonl(INADDR_ANY); // 使用哪个网卡加入,INADDR_ANY表示由系统选择
setsockopt(sockfd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq));
四、UDP 的应用
1. 适用场景
UDP 的高效和低延迟特性使其非常适合以下场景:
- 音视频流传输:如直播、视频会议。偶尔丢失一两帧数据不影响整体体验。
- 实时游戏:对实时性要求极高。
- DNS (域名解析):一次简短的请求和响应,追求速度。
- 网络发现协议:如 DHCP。
2. 示例:DNS 域名解析
Linux 提供了 gethostbyname() 函数来查询域名对应的 IP 地址。man 3 gethostbyname 查看接口函数。
1 |
|
TCP 协议
一、TCP 协议基础
1. TCP 协议的核心特点
TCP (Transmission Control Protocol) 是传输层与 UDP 并列的核心协议,但它的设计目标是可靠性。
- 面向连接 (Connection-Oriented):在数据传输前,通信双方必须先通过“三次握手”建立一个逻辑连接。通信结束后,通过“四次挥手”断开连接。
- 可靠传输 (Reliable):TCP 通过多种机制确保数据不丢失、不重复、无差错、按顺序到达。
- 全双工通信 (Full-Duplex):连接建立后,双方可以同时进行数据的发送和接收。
- 面向字节流 (Byte Stream-Oriented):应用程序发送的数据会被 TCP 视为一连串无边界的字节流。TCP 可能会根据网络状况对数据进行分段或合并,接收方需要自己处理“粘包”和“半包”问题。
一句话总结:TCP 就像打电话,必须先拨号接通,通话过程稳定可靠,说完后挂断,只支持一对一通话。
2. TCP 如何保证可靠性?
TCP 的可靠性并非凭空而来,而是依赖于其精巧的报头设计和一系列机制:
- 序列号 (Sequence Number):TCP 为每个发送的字节都编上号。接收方可以根据序列号来检测数据包的丢失、重组乱序的数据包。
- 确认应答 (ACK):接收方每收到一个数据包,都会发送一个 ACK 确认包,告知发送方“我收到了序列号 xxx 之前的所有数据”。
- 超时重传 (Timeout Retransmission):发送方在发出数据后会启动一个计时器。如果在规定时间内没有收到对方的 ACK,就会认为数据包丢失,并重新发送该数据包。
- 校验和 (Checksum):发送方和接收方都会对报头和数据进行校验和计算,以检测数据在传输过程中是否损坏。
- 流量控制 (Flow Control):通过报头中的“窗口大小”字段,接收方可以告知发送方自己还能接收多少数据,防止发送方发送过快导致接收方缓冲区溢出。
3. TCP 报头格式
TCP 的报头比 UDP 复杂得多,标准长度为 20 字节,包含了实现其可靠性所需的各种字段。
关键字段说明:
- 源/目标端口号:各 16 位,标识通信的两个进程。
- 序列号 (Sequence Number):32 位,标记本报文段数据第一个字节的序号。
- 确认应答号 (Acknowledgement Number):32 位,期望收到的下一个字节的序列号。
- 头部长度:4 位,表示 TCP 头部的长度,单位是 4 字节(32 位)。
- 控制位 (Flags):6 位,至关重要。
ACK: 确认位。SYN: 同步位,在建立连接时使用。FIN: 终止位,在断开连接时使用。
- 窗口大小 (Window Size):16 位,用于流量控制。
二、TCP 连接生命周期
1. 三次握手 (建立连接)
TCP 连接的建立过程由客户端发起,在 connect() 和 accept() 函数内部自动完成。
- 客户端 -> 服务器:客户端发送一个
SYN包(SYN=1),并携带一个随机生成的初始序列号seq=x。客户端进入SYN_SENT状态。 - 服务器 -> 客户端:服务器收到
SYN包后,回复一个SYN+ACK包(SYN=1, ACK=1),并携带自己的初始序列号seq=y和确认号ack=x+1。服务器进入SYN_RCVD状态。 - 客户端 -> 服务器:客户端收到服务器的确认后,再发送一个
ACK包(ACK=1),并携带确认号ack=y+1。连接建立成功,双方进入ESTABLISHED状态。
2. 四次挥手 (断开连接)
连接的断开可以由任意一方发起,在 close() 函数调用时触发。
- 主动方 -> 被动方:主动方发送一个
FIN包(FIN=1),表示我这边的数据已经发完了。 - 被动方 -> 主动方:被动方回复一个
ACK包,表示收到了你的断开请求。 - 被动方 -> 主动方:被动方处理完自己要发送的数据后,也发送一个
FIN包。 - 主动方 -> 被动方:主动方回复最后一个
ACK包。
TIME_WAIT 状态
主动断开方在发送完最后一个ACK后,会进入TIME_WAIT状态,并持续2*MSL(报文最大生存时间,通常是 60 秒) 的时间。
目的:
- 确保最后一个
ACK包能成功到达对方(如果丢失,对方会重传FIN,我方可以再次回复ACK)。- 防止网络中延迟的旧连接数据包干扰新建立的连接。
三、TCP 核心 Socket API
TCP 通信基本流程:
- 服务器:
socket()->bind()->listen()->accept()->recv()/send()->close() - 客户端:
socket()->connect()->send()/recv()->close()
1. 通用函数 socket()
man 2 socket 查看接口函数。
1 | int socket(int domain, int type, int protocol); |
- 作用:创建一个套接字(网络通信端点)。对于 TCP,
type必须是SOCK_STREAM。
2. 客户端专用函数 connect()
man 2 connect 查看接口函数。
1 | int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); |
- 作用:客户端使用此函数向服务器发起连接请求。函数内部会自动完成三次握手。成功返回 0,失败返回-1。
3. 服务器专用函数
a. bind() - 绑定地址和端口
man 2 bind 查看接口函数。
1 | int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); |
- 作用:将套接字与服务器的 IP 地址和端口号关联起来,告诉内核监听哪个端口。
b. listen() - 设置监听状态
man 2 listen 查看接口函数。
1 | int listen(int sockfd, int backlog); |
- 作用:将一个主动套接字转换为被动监听套接字,准备接受客户端的连接。
backlog定义了内核为这个套接字维护的、已完成三次握手但尚未被accept()的连接队列大小。
c. accept() - 接受连接
man 2 accept 查看接口函数。
1 | int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); |
- 作用:从已完成连接队列中取出一个连接。如果队列为空,
accept()会阻塞。 - 返回值:成功时返回一个全新的、已连接的套接字描述符,用于与该客户端进行通信。原来的监听套接字
sockfd保持不变,继续监听其他连接。
4. 数据收发函数
a. send() & write()
man 2 send 查看接口函数。
1 | ssize_t send(int sockfd, const void *buf, size_t len, int flags); |
- 作用:在已连接的套接字上发送数据。
flags通常为 0,此时功能与write()几乎相同。
b. recv() & read()
man 2 recv 查看接口函数。
1 | ssize_t recv(int sockfd, void *buf, size_t len, int flags); |
- 作用:从已连接的套接字接收数据。如果缓冲区无数据,
recv()会阻塞。 - 返回值:
> 0: 成功接收的字节数。= 0: 对方已正常关闭连接(发送了 FIN)。< 0: 发生错误。
四、TCP 编程示例
服务器
tcp_server.c1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
int main(void) {
// 1. 创建监听套接字
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 绑定地址和端口
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888);
server_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
// 3. 设置为监听状态
listen(listen_fd, 5);
printf("服务器正在监听 8888 端口...\n");
// 4. 接受客户端连接
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &len);
printf("接受来自 %s:%d 的连接\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
// 5. 接收数据
char buf[128];
int n = recv(conn_fd, buf, sizeof(buf), 0);
buf[n] = '\0';
printf("收到消息: %s\n", buf);
// 6. 关闭套接字
close(conn_fd);
close(listen_fd);
return 0;
}客户端
tcp_client.c1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int main(void) {
// 1. 创建套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 连接服务器
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888);
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 连接本机
if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("connect failed");
return -1;
}
// 3. 发送数据
char *msg = "Hello, TCP Server!";
send(sockfd, msg, strlen(msg), 0);
printf("消息已发送。\n");
// 4. 关闭套接字
close(sockfd);
return 0;
}
五、高级 TCP 特性
1. 缓冲区管理
TCP 套接字在内核中拥有独立的发送缓冲区 (Send Buffer) 和接收缓冲区 (Receive Buffer)。send() 实际上是将数据写入发送缓冲区,而 recv() 是从接收缓冲区读取数据。我们可以通过 setsockopt 来调整这些缓冲区的行为。
a. 缓冲区大小 (SO_SNDBUF / SO_RCVBUF)
- 作用:获取或设置 TCP 发送/接收缓冲区的大小。调整此值可以影响 TCP 的吞吐量和性能,尤其是在高延迟或高带宽网络中。
- 注意:设置的值只是给内核的一个“建议”,内核会自动将其调整(通常是加倍)以预留管理开销。设置应在
listen()或connect()之前进行。
b. 接收低水位标记 (SO_RCVLOWAT)
- 作用:设置接收缓冲区的“水位线”。只有当缓冲区中待读取的数据量达到或超过这个阈值时,
select/poll/epoll等 I/O 复用函数才会认为该套接字是“可读”的。默认值为 1。 - 场景:可以避免频繁地被少量数据唤醒,实现“攒够一定量的数据再处理”,提高处理效率。
getsockopt() / setsockopt() - 获取/设置套接字选项
man 2 getsockopt 查看接口函数。
1 | int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen); |
- 作用:用于获取或配置套接字的各种底层参数。
示例代码:查询与设置缓冲区参数
该程序演示了如何查询套接字的默认缓冲区大小和低水位标记,然后根据用户输入进行设置,并验证设置后的结果。
1 | // buffer_options.c |
2. 带外数据 (Out-of-Band, OOB)
- 作用:提供一种发送“紧急”数据的方式。它允许发送一个字节的数据,这个数据在逻辑上独立于正常的 TCP 字节流,可以被接收方优先处理,而无需等待缓冲区中的普通数据被读完。
- 实现机制:
- 发送方: 调用
send()时,将flags参数设置为MSG_OOB。 - 接收方: 当 OOB 数据到达时,内核会向接收进程发送
SIGURG信号。进程需要:
a. 使用signal()或sigaction()捕捉SIGURG信号。
b. 使用fcntl()的F_SETOWN命令,将套接字的所有权指定给当前进程,这样内核才知道该把信号发给谁。
c. 在信号处理函数中,调用recv()并将flags参数设置为MSG_OOB来读取这个紧急字节。
- 发送方: 调用
示例代码:发送和接收带外数据
服务器 (接收端)
server_oob.c1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48// server_oob.c
int conn_fd;
void oob_handler(int sig) {
char oob_data;
// 使用 MSG_OOB 标志接收紧急数据
recv(conn_fd, &oob_data, 1, MSG_OOB);
printf("\n>>> 收到紧急数据: %c <<<\n", oob_data);
}
int main(void) {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = INADDR_ANY;
bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr));
listen(listen_fd, 5);
printf("等待连接...\n");
conn_fd = accept(listen_fd, NULL, NULL);
printf("客户端已连接。\n");
// 1. 设置信号处理函数
signal(SIGURG, oob_handler);
// 2. 指定当前进程为信号的接收者
fcntl(conn_fd, F_SETOWN, getpid());
char buf[128];
while (1) {
int n = recv(conn_fd, buf, sizeof(buf) - 1, 0);
if (n <= 0) break;
buf[n] = '\0';
printf("收到普通数据: %s\n", buf);
}
close(conn_fd);
close(listen_fd);
return 0;
}客户端 (发送端)
client_oob.c1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30// client_oob.c
int main(void) {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
connect(sockfd, (struct sockaddr *)&addr, sizeof(addr));
send(sockfd, "normal_data_1", 13, 0);
printf("发送普通数据 'normal_data_1'\n");
sleep(1);
send(sockfd, "A", 1, MSG_OOB); // 发送紧急数据
printf("发送紧急数据 'A'\n");
sleep(1);
send(sockfd, "normal_data_2", 13, 0);
printf("发送普通数据 'normal_data_2'\n");
close(sockfd);
return 0;
}
3. 超时控制
- 作用:为阻塞的套接字操作(如
accept,connect,recv)设置一个最长等待时间。如果在超时时间内操作未能完成(如没等到连接、没收到数据),函数将不再阻塞,而是立即返回一个错误,通常errno会被设为EAGAIN或EWOULDBLOCK。这可以防止程序因网络问题而无限期挂起。 - 实现:通过
setsockopt设置SO_RCVTIMEO(接收超时) 或SO_SNDTIMEO(发送超时) 选项。超时时间通过struct timeval结构体指定。
struct timeval 结构体
man 3 timeval 查看接口函数。
1 |
|
示例代码:带超时的服务器
这个服务器演示了两种超时:
accept超时:如果在 5 秒内没有客户端连接,accept将超时返回。recv超时:连接成功后,如果在 5 秒内没有收到客户端的任何数据,recv将超时返回。
1 | // timeout_server.c |
六、服务器 I/O 模型
当服务器需要同时处理多个客户端时,如何高效地管理这些连接就成了关键问题。
- 非阻塞轮询:将所有套接字设为非阻塞,然后在一个死循环中不断轮询每个套接字是否有数据。缺点:CPU 空转,效率低下。
- 多任务并发 (多进程/多线程):每当
accept()一个新连接,就创建一个新的进程或线程专门为这个客户端服务。缺点:资源开销大,能支持的并发连接数有限。 - I/O 多路复用 (Multiplexing):推荐方案。使用
select(),poll(),epoll()等函数,让一个线程可以同时监视多个文件描述符(套接字)的状态。当某个或某些描述符就绪(可读/可写)时,函数返回,程序再去处理这些就绪的描述符。- 优点:资源开销小,能够高效地处理大量并发连接。
select() 模型详解
man 2 select 查看接口函数。
1 | int select(int nfds, fd_set *readfds, fd_set *writefds, |
- 作用:阻塞地等待一组文件描述符中的一个或多个变为就绪状态。
fd_set: 一个位图,用于表示文件描述符的集合。需要使用以下宏来操作:FD_ZERO(fd_set *set): 清空集合。FD_SET(int fd, fd_set *set): 将一个 fd 加入集合。FD_CLR(int fd, fd_set *set): 将一个 fd 从集合中移除。FD_ISSET(int fd, fd_set *set): 判断一个 fd 是否仍在集合中(即是否就绪)。
select 工作流程
- 创建一个
fd_set(例如readfds)。 - 使用
FD_ZERO清空它。 - 将所有需要监视的
fd(监听套接字、所有已连接的客户端套接字) 通过FD_SET加入集合。 - 调用
select()。函数会阻塞。 - 当有
fd状态就绪时,select返回,并修改传入的fd_set,只保留那些就绪的fd。 - 遍历所有原始的
fd,使用FD_ISSET检查哪个fd仍然在集合中,然后对其进行相应的读写操作。 - 重复以上步骤(
select会修改集合,所以每次循环前都必须重新设置)。
核心考点总结
第一部分:UDP (用户数据报协议)
1. UDP 核心特性 (必考)
- 无连接:发送前不需要建立连接(不“握手”)。
- 不可靠:不保证数据送达,不保证顺序,不重传。可靠性需应用层自己实现。
- 面向数据报:保持应用层消息的边界,不会拆分或合并数据包。
- 效率高、延迟低:开销小,适用于实时性要求高的场景。
- 报头简单:仅 8 字节。
2. UDP 编程流程与核心 API
- 服务器流程:
socket()->bind()->recvfrom()->close() - 客户端流程:
socket()->sendto()->close()
| 函数 | 作用 | 考点 |
|---|---|---|
socket() | 创建套接字 | socket(AF_INET, SOCK_DGRAM, 0); |
bind() | (服务器) 绑定 IP 和端口 | 必须将主机字节序的端口和地址转为网络字节序(大端序) |
sendto() | (客户端) 发送数据 | 需要指定目标地址 dest_addr |
recvfrom() | (服务器) 接收数据 | 阻塞函数,会返回源地址 src_addr |
3. 关键概念与函数
- 网络字节序:网络协议规定使用大端序。必须使用
htons()(端口) 和htonl()(IP) 函数进行转换。 TIME_WAIT问题:程序关闭后端口会短时间被占用,导致重启失败,报 “Address already in use”。setsockopt()与SO_REUSEADDR:解决TIME_WAIT问题的关键。在bind()之前调用setsockopt()设置地址复用选项,可以立即重启程序。- 广播 (Broadcast):向局域网所有主机发送。
- 发送方需要用
setsockopt()开启SO_BROADCAST权限。 - 目标地址是广播地址(如
192.168.1.255)。
- 发送方需要用
- 组播 (Multicast):向特定“小组”内的主机发送。
- 使用 D 类 IP 地址 (
224.0.0.0-239.255.255.255)。 - 接收方需要用
setsockopt()的IP_ADD_MEMBERSHIP选项加入组播组。
- 使用 D 类 IP 地址 (
第二部分:TCP (传输控制协议)
1. TCP 核心特性 (必考)
- 面向连接:传输数据前必须通过“三次握手”建立连接,结束后“四次挥手”断开。
- 可靠传输:通过序列号、确认应答(ACK)、超时重传、流量控制等机制保证数据不丢、不乱、无误。
- 面向字节流:数据没有边界,可能会发生“粘包”或“半包”问题,需要应用层处理。
- 全双工:连接建立后,双方可同时收发数据。
2. TCP 连接生命周期 (高频考点)
- 三次握手 (建立连接):
- 客户端
SYN-> 服务器 - 服务器
SYN+ACK-> 客户端 - 客户端
ACK-> 服务器
- 客户端
- 四次挥手 (断开连接):
- 主动方
FIN-> 被动方 - 被动方
ACK-> 主动方 - 被动方
FIN-> 主动方 - 主动方
ACK-> 被动方
- 主动方
TIME_WAIT状态:- 谁产生:主动断开连接的一方。
- 目的:1. 确保最后一个 ACK 能成功送达;2. 防止延迟的旧数据包干扰新连接。
3. TCP 编程流程与核心 API
- 服务器流程:
socket()->bind()->listen()->accept()->recv()/send()->close() - 客户端流程:
socket()->connect()->send()/recv()->close()
| 函数 | 作用 | 考点 |
|---|---|---|
socket() | 创建套接字 | socket(AF_INET, SOCK_STREAM, 0); |
connect() | (客户端) 发起连接请求 | 内部自动完成三次握手,是阻塞函数。 |
listen() | (服务器) 将套接字设为监听状态 | 第二个参数 backlog 定义了等待连接队列的大小。 |
accept() | (服务器) 从队列中接受连接 | 阻塞函数。成功时会返回一个全新的已连接套接字用于通信。 |
recv() | 接收数据 | 阻塞函数。返回 >0 (字节数),=0 (对方正常关闭),<0 (出错)。 |
send() | 发送数据 | - |
第三部分:高级特性与 I/O 模型
1. 高级 TCP 特性
- 缓冲区管理:
SO_RCVBUF/SO_SNDBUF:设置接收/发送缓冲区大小,影响吞吐量。
- 超时控制 (必考):
- 作用:防止
accept、connect、recv等阻塞函数因网络问题无限期挂起。 - 实现:使用
setsockopt()设置SO_RCVTIMEO(接收超时) 或SO_SNDTIMEO(发送超时)。超时后函数返回 -1,errno为EAGAIN或EWOULDBLOCK。
- 作用:防止
- 带外数据 (OOB):
- 发送一个字节的“紧急”数据,接收方会收到
SIGURG信号。 - 通过
send()的MSG_OOB标志发送,recv()的MSG_OOB标志接收。
- 发送一个字节的“紧急”数据,接收方会收到
2. 服务器 I/O 模型 (必考)
- 问题:如何高效处理大量并发连接?
- 解决方案:I/O 多路复用(重点是
select)。 select()模型:- 作用:让一个线程同时监视多个套接字(文件描述符)的状态。
- 核心:使用
fd_set位图结构来管理文件描述符集合。 - 关键宏:
FD_ZERO(清空),FD_SET(添加),FD_CLR(移除),FD_ISSET(检查)。 - 工作流程:
FD_ZERO清空fd_set。FD_SET将所有要监视的套接字加入集合。- 调用
select()进行阻塞等待。 select()返回后,fd_set中只剩下就绪的套接字。- 使用
FD_ISSET遍历,找出哪些套接字就绪并进行处理。 - 易错点:每次循环调用
select之前,必须重新设置fd_set,因为它会被内核修改。


