第4章 基本的TCP套接字编程
TCP编程
一、导读
4.1 本章学习目标
第3章介绍了建立和关闭TCP连接的基本API,本章则聚焦于TCP连接建立后的数据传输操作。你将深入学习 read、write、recv、send、close 和 shutdown 等函数的行为细节,理解TCP字节流服务的本质特征,学会处理网络编程中最常见的边界情况。
本章的核心学习目标包括:
- 理解TCP字节流无消息边界的本质:为什么
write两次的数据可能被read一次读回?如何在应用层定义消息边界? - 掌握部分读/写的处理方法:
read和write可能返回的字节数少于请求值,如何正确封装readn和writen? - 理解 `close` 与 `shutdown` 的区别:为什么网络程序有时需要用
shutdown而不是close? - 掌握TCP四次挥手的完整过程:FIN的含义、TIME_WAIT的作用、如何优雅地关闭连接?
- 处理EINTR中断:系统调用被信号中断后如何正确重试?
- 理解SIGPIPE信号:向已关闭的连接写入数据会发生什么?如何避免进程被意外终止?
4.2 为什么本章如此重要
数据传输是网络编程的核心操作,也是最容易出错的地方。Stevens在书中反复强调:TCP是字节流协议,不是消息协议。这个看似简单的概念,却是无数网络程序bug的根源。
典型的错误场景:
- 发送端调用两次
write("Hello")和write("World"),期望接收端两次read分别得到 "Hello" 和 "World"。实际上接收端可能一次read得到 "HelloWorld",或者分三次读到 "H"、"ello"、"World"。 - 发送端调用
write写入10000字节,但write只返回了5000(部分写入)。程序没有检查返回值,丢失了一半数据。 - 服务器关闭连接后,客户端继续写入数据,触发SIGPIPE信号导致进程被杀死。
理解本章的内容,能帮你避免这些常见陷阱,写出健壮的网络代码。
4.3 核心问题引导
在阅读本章时,请带着以下问题思考:
TCP的"字节流"具体意味着什么?为什么TCP不提供消息边界?
read 返回0和返回-1有什么区别?分别代表什么情况?
为什么 write 可能返回的字节数少于请求值?在什么情况下会发生部分写入?
close(sockfd) 和 shutdown(sockfd, SHUT_WR) 有什么区别?什么时候应该用后者?
如果服务器先调用 close 关闭连接,然后立即重启并 bind 同一端口,会发生什么?
SIGPIPE信号的默认行为是什么?为什么网络程序必须处理它?
二、核心概念详解
4.1 read和write函数
4.1.1 read函数
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
// 返回值:读取的字节数,0表示EOF,-1表示错误read 函数从文件描述符 fd 读取最多 count 字节的数据到 buf 中。
返回值分析:
| 返回值 | 含义 | 处理方式 |
|---|---|---|
| > 0 | 成功读取的字节数 | 正常处理数据 |
| = 0 | EOF(对端关闭连接) | 连接已关闭,停止读取 |
| = -1, errno=EINTR | 被信号中断 | 重试read |
| = -1, errno=EAGAIN | 非阻塞模式下无数据 | 稍后重试或使用I/O复用 |
| = -1, 其他 | 真正的错误 | 报告错误并关闭连接 |
// 正确的read处理方式
char buf[1024];
ssize_t n;
while ((n = read(connfd, buf, sizeof(buf))) > 0) {
// 处理数据 n 字节
process_data(buf, n);
}
if (n == 0) {
// 对端正常关闭连接(收到FIN)
printf("Connection closed by peer\n");
} else if (n < 0) {
if (errno == EINTR) {
// 被信号中断,可以重试
} else {
// 真正的错误
perror("read error");
}
}关键理解:read 返回的字节数可能少于请求的 count。这不是错误——它只是表示当前内核缓冲区中只有这么多数据可用。对于TCP套接字,read 的行为是:
- 如果缓冲区中有数据,立即返回(可能少于count字节)
- 如果缓冲区为空,阻塞等待数据到达
- 如果对端发送了FIN,返回0
// 常见错误:假设read一次读完所有数据
char buf[1024];
int n = read(connfd, buf, 1024);
// 假设发送端发送了2000字节
// n可能是1024(缓冲区满),剩余的976字节需要再次read
// 或者n可能是500(当前只有500字节到达),需要继续read
// 正确做法:循环读取直到满足需求
ssize_t readn(int fd, void *buf, size_t n); // 见4.2节4.1.2 write函数
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
// 返回值:写入的字节数,-1表示错误write 函数将 buf 中的 count 字节数据写入文件描述符 fd。
返回值分析:
| 返回值 | 含义 | 处理方式 |
|---|---|---|
| = count | 全部写入成功 | 正常继续 |
| < count | 部分写入 | 继续写入剩余数据 |
| = -1, errno=EINTR | 被信号中断 | 重试write |
| = -1, errno=EAGAIN | 非阻塞模式下缓冲区满 | 稍后重试 |
| = -1, errno=EPIPE | 连接已关闭 | 处理SIGPIPE |
| = -1, 其他 | 真正的错误 | 报告错误 |
// 常见错误:不检查write返回值
write(connfd, buf, 10000);
// 可能只写入了5000字节!剩余5000字节丢失
// 正确做法:循环写入直到全部完成
ssize_t writen(int fd, const void *buf, size_t n); // 见4.2节部分写入的原因:
- TCP发送缓冲区空间不足(内核限制了一次写入的大小)
- 网络拥塞,TCP拥塞窗口较小
- 数据量超过MSS,需要分段发送
4.2 readn和writen封装
由于 read 和 write 可能返回部分读/写,Stevens在书中提供了两个经典的封装函数:
4.2.1 readn函数
#include <sys/socket.h>
#include <errno.h>
ssize_t readn(int fd, void *buf, size_t n) {
size_t nleft = n;
ssize_t nread;
char *p = (char *)buf;
while (nleft > 0) {
if ((nread = read(fd, p, nleft)) < 0) {
if (errno == EINTR) {
nread = 0; // 被信号中断,重新读取0字节(继续循环)
} else {
return -1; // 真正的错误
}
} else if (nread == 0) {
break; // EOF
}
nleft -= nread;
p += nread;
}
return n - nleft; // 返回实际读取的字节数
}readn的工作原理:
维护一个循环,直到读取了 n 字节或遇到EOF
nleft 记录还需要读取的字节数
p 指向缓冲区中下一个要写入的位置
每次 read 后更新 nleft 和 p
遇到 EINTR 时不视为错误,继续循环
遇到 EOF(read 返回0)时退出循环,返回已读取的字节数
4.2.2 writen函数
ssize_t writen(int fd, const void *buf, size_t n) {
size_t nleft = n;
ssize_t nwritten;
const char *p = (const char *)buf;
while (nleft > 0) {
if ((nwritten = write(fd, p, nleft)) < 0) {
if (errno == EINTR) {
nwritten = 0; // 被信号中断,重新写入0字节
} else {
return -1; // 真正的错误
}
} else if (nwritten == 0) {
// write返回0通常不应该发生,但为安全起见处理
continue;
}
nleft -= nwritten;
p += nwritten;
}
return n;
}4.2.3 readline函数
// 从套接字读取一行文本(以\n结尾)
// 这是一个低效的实现,每次只读一个字节
ssize_t readline(int fd, void *buf, size_t maxlen) {
char *p = (char *)buf;
ssize_t n;
size_t i;
for (i = 1; i < maxlen; i++) {
if ((n = read(fd, p, 1)) == 1) {
if (*p++ == '\n') {
break; // 读到换行符
}
} else if (n == 0) {
if (i == 1) {
return 0; // EOF,没有读到任何数据
} else {
break; // EOF,但已读到一些数据
}
} else {
if (errno == EINTR) {
continue; // 被信号中断,重试
}
return -1; // 错误
}
}
*p = '\0';
return i;
}readline的效率问题:每次只读一个字节,系统调用开销很大。高效的实现使用缓冲区:
// 使用缓冲的高效readline
typedef struct {
int fd;
char *buf; // 缓冲区
char *buf_ptr; // 当前读取位置
char *buf_end; // 缓冲区末尾
size_t buf_size;
} ReadLine;
ReadLine *readline_alloc(int fd, size_t buf_size) {
ReadLine *rl = malloc(sizeof(ReadLine));
rl->fd = fd;
rl->buf = malloc(buf_size);
rl->buf_ptr = rl->buf;
rl->buf_end = rl->buf;
rl->buf_size = buf_size;
return rl;
}
ssize_t readline_buffered(ReadLine *rl, char *buf, size_t maxlen) {
size_t i;
for (i = 0; i < maxlen - 1; i++) {
if (rl->buf_ptr >= rl->buf_end) {
// 缓冲区空了,重新读取
ssize_t n = read(rl->fd, rl->buf, rl->buf_size);
if (n <= 0) {
if (i > 0) break;
return n;
}
rl->buf_ptr = rl->buf;
rl->buf_end = rl->buf + n;
}
buf[i] = *rl->buf_ptr++;
if (buf[i] == '\n') {
i++;
break;
}
}
buf[i] = '\0';
return i;
}4.3 recv和send函数
4.3.1 recv函数
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
// 返回值:读取的字节数,0表示对端关闭,-1表示错误recv 与 read 类似,但多了一个 flags 参数,提供更细粒度的控制:
| 标志 | 说明 |
|---|---|
| MSG_PEEK | 查看数据但不从内核缓冲区移除 |
| MSG_WAITALL | 等待所有请求的数据到达 |
| MSG_DONTWAIT | 非阻塞操作(即使套接字是阻塞的) |
| MSG_OOB | 接收带外数据 |
// MSG_PEEK:偷看数据,不消耗
char buf[1024];
ssize_t n = recv(sockfd, buf, sizeof(buf), MSG_PEEK);
// 数据仍在内核缓冲区中,下次recv/read会再次读到
// MSG_WAITALL:等待所有数据
// 等价于readn的效果,但由内核实现
char buf[1000];
ssize_t n = recv(sockfd, buf, 1000, MSG_WAITALL);
// n应该等于1000(除非连接关闭或出错)
// 组合使用
ssize_t n = recv(sockfd, buf, sizeof(buf), MSG_WAITALL | MSG_PEEK);
// 等待1000字节全部到达,但不消耗4.3.2 send函数
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
// 返回值:写入的字节数,-1表示错误send 与 write 类似,但同样有 flags 参数:
// MSG_DONTWAIT:非阻塞发送
ssize_t n = send(sockfd, buf, len, MSG_DONTWAIT);
if (n < 0 && errno == EAGAIN) {
// 发送缓冲区满,稍后重试
}
// MSG_NOSIGNAL:写入已关闭的连接时不产生SIGPIPE
ssize_t n = send(sockfd, buf, len, MSG_NOSIGNAL);
if (n < 0 && errno == EPIPE) {
// 对端已关闭连接
}recv/send vs read/write:
recv/send只能用于套接字描述符,read/write可以用于任何文件描述符recv/send有flags参数,提供更细粒度的控制- 对于TCP套接字,
recv(fd, buf, len, 0)等价于read(fd, buf, len) - 对于TCP套接字,
send(fd, buf, len, 0)等价于write(fd, buf, len)
4.4 close和shutdown函数
4.4.1 close函数
#include <unistd.h>
int close(int sockfd);
// 返回值:成功返回0,出错返回-1close 关闭套接字。对于TCP套接字,close 触发四次挥手:
发送FIN给对方,表示不再发送数据
等待对方发送FIN
收到对方的FIN后发送ACK
进入TIME_WAIT状态(等待2MSL)
close的引用计数行为:
每个文件描述符在内核中有一个引用计数。fork() 后父子进程共享文件描述符,引用计数增加。close 只在引用计数减到0时才真正关闭连接。
// fork后的引用计数
// 父进程: listenfd (ref=1), connfd (ref=1)
// fork()
// 子进程: connfd (ref=2,与父进程共享)
//
// 子进程 close(connfd) → ref=1,连接不关闭!
// 父进程 close(connfd) → ref=0,连接关闭,发送FIN
// 这就是为什么并发服务器中,子进程必须close(listenfd)
// 父进程必须close(connfd)
// 否则引用计数不会正确减到04.4.2 shutdown函数
#include <sys/socket.h>
int shutdown(int sockfd, int how);
// 返回值:成功返回0,出错返回-1shutdown 提供更精细的关闭控制:
| how参数 | 说明 |
|---|---|
| SHUT_RD | 关闭读端:不再接收数据,缓冲区中的数据被丢弃 |
| SHUT_WR | 关闭写端:发送FIN,不再发送数据 |
| SHUT_RDWR | 关闭读写两端 |
shutdown vs close:
| 特性 | close | shutdown |
|---|---|---|
| 引用计数 | 减1,到0才关闭 | 忽略引用计数,立即生效 |
| 方向控制 | 关闭读写两端 | 可以只关闭读或只关闭写 |
| 文件描述符 | 不再可用 | 仍然可用(取决于how) |
| 适用场景 | 普通关闭 | 半关闭(half-close) |
// 半关闭(half-close)的应用场景
// 场景:客户端发送完所有数据后,通知服务器"我发完了"
// 但客户端还想接收服务器的响应
// 客户端
write(sockfd, request_data, request_len);
shutdown(sockfd, SHUT_WR); // 发送FIN,表示"我发完了"
// 仍然可以read(sockfd, ...)
// 服务器
while ((n = read(connfd, buf, sizeof(buf))) > 0) {
process_request(buf, n);
}
// read返回0 → 客户端发完了
// 处理完请求后发送响应
write(connfd, response_data, response_len);
close(connfd);
// 客户端
char response[4096];
ssize_t n = read(sockfd, response, sizeof(response));
// 收到服务器的响应半关闭的典型应用:
- HTTP/1.0:客户端发送请求后关闭写端,服务器发送响应后关闭连接
- 文件传输:客户端发送文件后关闭写端,服务器确认接收完毕后关闭连接
- 任何"请求-响应"模式,且发送方需要知道发送是否完成的场景
4.5 TCP的字节流特性
4.5.1 无消息边界
TCP是字节流协议,它不保留消息边界。这是TCP协议设计的基本特征,不是bug。
// 发送端
write(sockfd, "Hello", 5); // 消息1
write(sockfd, "World", 5); // 消息2
// 接收端可能的结果:
// 情况1: read返回 "HelloWorld" (10字节) - 两个消息合并
// 情况2: read返回 "Hello" (5字节),再read返回 "World" (5字节) - 恰好分开
// 情况3: read返回 "Hel" (3字节),再read返回 "loWor" (5字节),再read返回 "ld" (2字节) - 碎片化
// 情况4: read返回 "H" (1字节),再read返回 "elloWorld" (9字节) - 不均匀分割为什么TCP不保留消息边界?
TCP的设计目标是提供可靠的字节流传输,而不是消息传输。TCP的确认机制基于字节序号,而不是消息序号。TCP的流量控制和拥塞控制也是基于字节数的。如果TCP要保留消息边界,就需要在每个消息上加标记,增加协议复杂度,而且与应用层的消息格式可能不匹配。
4.5.2 应用层定义消息边界
既然TCP不提供消息边界,就必须在应用层自己定义。常见的方法:
方法1:固定长度消息
// 每个消息固定100字节
#define MSG_SIZE 100
// 发送
char msg[MSG_SIZE];
memset(msg, 0, MSG_SIZE);
memcpy(msg, data, data_len); // 不足100字节用0填充
writen(sockfd, msg, MSG_SIZE);
// 接收
char buf[MSG_SIZE];
readn(sockfd, buf, MSG_SIZE); // 总是读100字节方法2:分隔符
// 使用换行符\n作为消息分隔符(如HTTP、FTP)
// 发送
char msg[1024];
snprintf(msg, sizeof(msg), "%s\n", data);
writen(sockfd, msg, strlen(msg));
// 接收
char line[1024];
readline(sockfd, line, sizeof(line));
// line现在包含一个完整的消息(包括\n)方法3:消息头包含长度
// 消息格式:[4字节长度][数据]
// 发送
struct {
uint32_t length; // 网络字节序
} header;
header.length = htonl(data_len);
writen(sockfd, &header, sizeof(header));
writen(sockfd, data, data_len);
// 接收
struct {
uint32_t length;
} header;
readn(sockfd, &header, sizeof(header));
uint32_t msg_len = ntohl(header.length);
char *data = malloc(msg_len);
readn(sockfd, data, msg_len);4.6 SIGPIPE信号
4.6.1 问题描述
当向一个已关闭的连接写入数据时,内核会发送SIGPIPE信号给进程。SIGPIPE的默认行为是终止进程。
// 场景:服务器先关闭连接,客户端继续写入
// 1. 服务器 close(connfd) → 发送FIN给客户端
// 2. 客户端 read() 返回0(检测到FIN)
// 3. 客户端 write() → 收到RST(因为服务器已经关闭)
// 4. 内核向客户端进程发送SIGPIPE信号
// 5. 默认行为:进程被终止!
// 如果客户端在服务器关闭后写入两次:
// 第1次write:返回-1,errno=EPIPE
// 第2次write:收到SIGPIPE信号,进程被终止4.6.2 处理方法
// 方法1:忽略SIGPIPE信号(推荐)
signal(SIGPIPE, SIG_IGN);
// 之后write到已关闭的连接会返回-1,errno=EPIPE
// 进程不会被终止
// 方法2:使用send的MSG_NOSIGNAL标志
send(sockfd, buf, len, MSG_NOSIGNAL);
// 这次write不会产生SIGPIPE,返回-1,errno=EPIPE
// 方法3:捕获SIGPIPE信号
struct sigaction sa;
sa.sa_handler = sigpipe_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGPIPE, &sa, NULL);
void sigpipe_handler(int sig) {
// 注意:信号处理函数中只能做有限操作
// 最好只设置一个标志,在主循环中处理
sigpipe_received = 1;
}推荐做法:在程序启动时调用 signal(SIGPIPE, SIG_IGN),然后在每次 write 后检查返回值和 errno。
4.7 TCP数据格式与选项
4.7.1 TCP_NODELAY选项
TCP默认使用Nagle算法:如果发送缓冲区中有少量未确认的数据,新的写入会被延迟,等待更多数据积累或收到确认。这减少了小数据包的发送,提高了网络效率。
但在某些场景下(如交互式应用),需要立即发送数据:
// 禁用Nagle算法
int nodelay = 1;
setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &nodelay, sizeof(nodelay));
// 之后write/send立即发送数据,不等待积累
// 适用场景:
// - 交互式应用(SSH、游戏)
// - 实时通信
// - 需要低延迟的场景
// 不适用场景:
// - 批量数据传输(文件传输)
// - 不关心延迟的应用4.7.2 SO_KEEPALIVE选项
TCP连接建立后,如果一方突然崩溃(如断电),另一方不会立即知道。SO_KEEPALIVE 启用TCP的保活机制:
// 启用keepalive
int keepalive = 1;
setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive));
// keepalive参数调整(Linux)
int keepidle = 60; // 空闲60秒后开始发送探测
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle));
int keepintvl = 10; // 每10秒发送一次探测
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl));
int keepcnt = 5; // 最多发送5次探测
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));
// 如果5次探测都无响应,连接被认为已断开
// 下次read/write会返回错误keepalive vs 应用层心跳:
- TCP keepalive是协议层机制,对应用透明,但默认间隔很长(通常2小时)
- 应用层心跳更灵活,可以自定义间隔和超时时间,推荐在应用层实现
三、实战案例
4.1 完整的TCP回显服务器(带健壮的数据传输)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <signal.h>
#include <errno.h>
#define MAXLINE 4096
#define LISTENQ 128
ssize_t readn(int fd, void *buf, size_t n) {
size_t nleft = n;
ssize_t nread;
char *p = (char *)buf;
while (nleft > 0) {
if ((nread = read(fd, p, nleft)) < 0) {
if (errno == EINTR) continue;
return -1;
} else if (nread == 0) {
break;
}
nleft -= nread;
p += nread;
}
return n - nleft;
}
ssize_t writen(int fd, const void *buf, size_t n) {
size_t nleft = n;
ssize_t nwritten;
const char *p = (const char *)buf;
while (nleft > 0) {
if ((nwritten = write(fd, p, nleft)) < 0) {
if (errno == EINTR) continue;
return -1;
}
nleft -= nwritten;
p += nwritten;
}
return n;
}
void str_echo(int connfd) {
char buf[MAXLINE];
ssize_t n;
while ((n = readn(connfd, buf, MAXLINE)) > 0) {
if (writen(connfd, buf, n) != n) {
perror("write error");
break;
}
}
if (n < 0) {
perror("read error");
}
}
int main(int argc, char **argv) {
int listenfd, connfd;
struct sockaddr_in servaddr;
signal(SIGPIPE, SIG_IGN);
signal(SIGCHLD, SIG_IGN);
if (argc != 2) {
fprintf(stderr, "usage: %s <port>\n", argv[0]);
exit(1);
}
listenfd = socket(AF_INET, SOCK_STREAM, 0);
int optval = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(atoi(argv[1]));
bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
listen(listenfd, LISTENQ);
printf("Echo server on port %s\n", argv[1]);
for (;;) {
struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);
connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
if (connfd < 0) {
if (errno == EINTR) continue;
perror("accept error");
continue;
}
pid_t pid = fork();
if (pid == 0) {
close(listenfd);
str_echo(connfd);
close(connfd);
_exit(0);
}
close(connfd);
}
return 0;
}4.2 带长度前缀的消息协议
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <errno.h>
#define MAX_MSG_SIZE 65535
ssize_t readn(int fd, void *buf, size_t n) {
size_t nleft = n;
ssize_t nread;
char *p = (char *)buf;
while (nleft > 0) {
if ((nread = read(fd, p, nleft)) < 0) {
if (errno == EINTR) continue;
return -1;
} else if (nread == 0) break;
nleft -= nread;
p += nread;
}
return n - nleft;
}
ssize_t writen(int fd, const void *buf, size_t n) {
size_t nleft = n;
ssize_t nwritten;
const char *p = (const char *)buf;
while (nleft > 0) {
if ((nwritten = write(fd, p, nleft)) < 0) {
if (errno == EINTR) continue;
return -1;
}
nleft -= nwritten;
p += nwritten;
}
return n;
}
int send_message(int fd, const char *msg, size_t len) {
if (len > MAX_MSG_SIZE) return -1;
uint32_t net_len = htonl(len);
if (writen(fd, &net_len, 4) != 4) return -1;
if (writen(fd, msg, len) != (ssize_t)len) return -1;
return 0;
}
int recv_message(int fd, char *buf, size_t bufsize, size_t *msg_len) {
uint32_t net_len;
if (readn(fd, &net_len, 4) != 4) return -1;
*msg_len = ntohl(net_len);
if (*msg_len > bufsize) return -1;
if (readn(fd, buf, *msg_len) != (ssize_t)*msg_len) return -1;
return 0;
}
// 使用示例
int main(void) {
int fds[2];
socketpair(AF_UNIX, SOCK_STREAM, 0, fds);
send_message(fds[0], "Hello, World!", 13);
send_message(fds[0], "Second message", 14);
char buf[256];
size_t len;
recv_message(fds[1], buf, sizeof(buf), &len);
buf[len] = '\0';
printf("Message 1 (%zu bytes): %s\n", len, buf);
recv_message(fds[1], buf, sizeof(buf), &len);
buf[len] = '\0';
printf("Message 2 (%zu bytes): %s\n", len, buf);
close(fds[0]);
close(fds[1]);
return 0;
}四、本章小结
本章深入讲解了TCP套接字的数据传输操作,核心要点回顾:
TCP字节流无消息边界:write 两次的数据可能被 read 一次读回,也可能被拆成多次读取。必须在应用层定义消息边界(固定长度、分隔符或长度前缀)。
部分读/写:read 和 write 可能返回的字节数少于请求值。必须使用 readn 和 writen 封装来确保读取/写入完整的消息。
read返回值:>0 表示读到数据,=0 表示EOF(对端关闭连接),=-1 表示错误(注意区分EINTR和真正的错误)。
close vs shutdown:close 受引用计数影响,关闭读写两端;shutdown 忽略引用计数,可以单独关闭读或写。半关闭(SHUT_WR)用于"请求-响应"模式。
SIGPIPE:向已关闭连接写入会触发SIGPIPE,默认终止进程。必须用 signal(SIGPIPE, SIG_IGN) 忽略或使用 MSG_NOSIGNAL。
TCP选项:TCP_NODELAY 禁用Nagle算法减少延迟;SO_KEEPALIVE 检测死连接。
五、思考与练习
思考题
如果 readn 在读到一半时收到FIN(read 返回0),函数返回什么值?调用者如何区分"读到了部分数据后连接关闭"和"连接一开始就关闭"?
为什么 writen 不需要处理 read 返回0的情况?write 可能返回0吗?
如果服务器使用 shutdown(connfd, SHUT_RD) 而不是 SHUT_WR,客户端的行为会怎样?
TCP_NODELAY 和 SO_SNDBUF 之间有什么交互关系?增大发送缓冲区是否减少了TCP_NODELAY的必要性?
为什么 readline 的低效实现中,每次只读一个字节?能否一次读多个字节到内部缓冲区?如何实现?
编程练习
实现消息分片协议:设计一个消息协议,支持大消息分片传输。消息头包含:总长度、分片序号、总分片数。接收端将所有分片重组为完整消息。
实现带超时的readn:在 readn 基础上增加超时参数。如果在指定时间内没有读到足够的数据,返回已读到的字节数。提示:使用 setsockopt 设置 SO_RCVTIMEO。
实现双向回显服务器:服务器同时回显客户端发来的数据,并定期向客户端发送心跳消息。使用半关闭实现:客户端发送完数据后 shutdown(SHUT_WR),服务器检测到EOF后也关闭写端。