UNIX网络编程 - Chapter 5 TCP 客户/服务器程序示例

Chapter 5 TCP 客户/服务器程序示例

这一章主要把 TCP 客户端/服务器在代码层面的关键点串起来:read() 的返回值语义、socket 的“身份信息”(本地/远端地址端口)、以及并发服务器里子进程回收(僵尸进程)的问题。

read() 返回值语义

  • > 0:实际读到的字节数
  • == 0:对端关闭(EOF),再读也读不到数据
  • -1:出错,需要结合 errno 判断
    • EAGAIN / EWOULDBLOCK:非阻塞读且当前无数据可读
    • EINTR:慢速系统调用被信号中断(通常可以重试)
    • 其他:真实错误,需要按场景处理

Socket 地址结构(内核视角)

下面是一个简化版示意:内核为每个 socket 维护状态、本地/远端地址端口、收发缓冲区等信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 内核为每个 socket 维护的数据结构(简化版)
struct socket {
// 连接状态
int state;

// 本地地址和端口
struct sockaddr_in local_addr;

// 远程地址和端口
struct sockaddr_in remote_addr;

// 接收缓冲区(读缓冲区)
struct sk_buff_head receive_queue;

// 发送缓冲区(写缓冲区)
struct sk_buff_head send_queue;

// TCP 连接状态信息
struct tcp_info tcp_state;

// 其他控制信息...
};

flowchart LR
	subgraph UserSpace [用户进程空间]
		direction TB
		APP["你的应用程序<br>(Client/Server)"]
		FD["文件描述符 (FD)"]
		APP <-->|读 / 写| FD
	end

	subgraph KernelSpace [操作系统内核]
		direction TB
		SOCK{"struct socket<br/>(套接字大管家)"}

		STATE(("连接状态<br/>(state)"))

		subgraph Addr [身份信息]
			LOCAL["本地地址<br/>(local_addr)"]
			REMOTE["远程地址<br/>(remote_addr)"]
		end

		subgraph Buffers [缓冲区]
			RECV["接收队列<br/>(receive_queue)"]
			SEND["发送队列<br/>(send_queue)"]
		end

		TCPCB["TCP 状态控制<br/>(tcp_state)"]

		SOCK --- STATE
		SOCK --- Addr
		SOCK --- Buffers
		SOCK --- TCPCB
	end

	subgraph Hardware [物理网络设备]
		NIC["网卡驱动 & 物理网卡"]
		INTERNET(("Internet"))
		NIC <--> INTERNET
	end

	%% 数据流转路径
	FD == "read() / recv()" === RECV
	FD == "write() / send()" === SEND

	RECV -. "内核协议栈解析后放入" .-> NIC
	SEND -. "内核协议栈打包后发出" .-> NIC

子进程回收:为什么要处理 SIGCHLD

在多进程并发服务器中,子进程退出后会向父进程发送 SIGCHLD 信号:

  • 默认行为通常是忽略,但这不等价于“自动回收”。
  • 子进程终止后,内核会保留进程表条目(PID、退出状态等)。父进程若不 wait()/waitpid(),子进程会变成僵尸进程。
  • 僵尸进程大量积累会导致 PID 资源被耗尽,影响后续 fork()

典型做法:注册 SIGCHLD 处理函数,在里面循环 waitpid(-1, ..., WNOHANG) 回收所有已退出的子进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1) 定义信号处理函数来回收子进程
void catch_child(int signum) {
// 使用 while 是因为多个 SIGCHLD 可能会合并,避免漏收
while (waitpid(-1, NULL, WNOHANG) > 0) {
}
}

// 2) 在 accept 之前注册信号处理函数
signal(SIGCHLD, catch_child);

// 3) 处理 accept 被信号打断的情况
for (;;) {
int cfd = accept(lfd, (struct sockaddr *)&client_addr, &client_len);
if (cfd < 0) {
if (errno == EINTR) continue; // 被信号打断,重启 accept
break;
}
// fork / close / 业务处理...
}

补充:read()/write() 不一定“读满/写满”

  • TCP 是字节流协议:一次 read() 可能读到一部分数据;一次 write() 也可能只写出部分数据。
  • 需要按协议自行“组包/拆包”,或者使用 readn/writen 这类循环读写封装。