4UNIX网络编程(第3版)

基本的TCP套接字编程

TCP编程

connectreadwriteclose

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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函数

c
#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);
// 返回值:读取的字节数,0表示EOF,-1表示错误

read 函数从文件描述符 fd 读取最多 count 字节的数据到 buf 中。

返回值分析:

返回值含义处理方式
> 0成功读取的字节数正常处理数据
= 0EOF(对端关闭连接)连接已关闭,停止读取
= -1, errno=EINTR被信号中断重试read
= -1, errno=EAGAIN非阻塞模式下无数据稍后重试或使用I/O复用
= -1, 其他真正的错误报告错误并关闭连接
c
// 正确的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
c
// 常见错误:假设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函数

c
#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, 其他真正的错误报告错误
c
// 常见错误:不检查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函数

c
#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函数

c
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函数

c
// 从套接字读取一行文本(以\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的效率问题:每次只读一个字节,系统调用开销很大。高效的实现使用缓冲区:

c
// 使用缓冲的高效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函数

c
#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接收带外数据
c
// 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函数

c
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
// 返回值:写入的字节数,-1表示错误

send 与 write 类似,但同样有 flags 参数:

c
// 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函数

c
#include <unistd.h>

int close(int sockfd);
// 返回值:成功返回0,出错返回-1

close 关闭套接字。对于TCP套接字,close 触发四次挥手:

发送FIN给对方,表示不再发送数据

等待对方发送FIN

收到对方的FIN后发送ACK

进入TIME_WAIT状态(等待2MSL)

close的引用计数行为:

每个文件描述符在内核中有一个引用计数。fork() 后父子进程共享文件描述符,引用计数增加。close 只在引用计数减到0时才真正关闭连接。

c
// fork后的引用计数
// 父进程: listenfd (ref=1), connfd (ref=1)
// fork()
// 子进程: connfd (ref=2,与父进程共享)
// 
// 子进程 close(connfd) → ref=1,连接不关闭!
// 父进程 close(connfd) → ref=0,连接关闭,发送FIN

// 这就是为什么并发服务器中,子进程必须close(listenfd)
// 父进程必须close(connfd)
// 否则引用计数不会正确减到0

4.4.2 shutdown函数

c
#include <sys/socket.h>

int shutdown(int sockfd, int how);
// 返回值:成功返回0,出错返回-1

shutdown 提供更精细的关闭控制:

how参数说明
SHUT_RD关闭读端:不再接收数据,缓冲区中的数据被丢弃
SHUT_WR关闭写端:发送FIN,不再发送数据
SHUT_RDWR关闭读写两端

shutdown vs close:

特性closeshutdown
引用计数减1,到0才关闭忽略引用计数,立即生效
方向控制关闭读写两端可以只关闭读或只关闭写
文件描述符不再可用仍然可用(取决于how)
适用场景普通关闭半关闭(half-close)
c
// 半关闭(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。

c
// 发送端
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:固定长度消息

c
// 每个消息固定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:分隔符

c
// 使用换行符\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:消息头包含长度

c
// 消息格式:[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的默认行为是终止进程。

c
// 场景:服务器先关闭连接,客户端继续写入
// 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 处理方法

c
// 方法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算法:如果发送缓冲区中有少量未确认的数据,新的写入会被延迟,等待更多数据积累或收到确认。这减少了小数据包的发送,提高了网络效率。

但在某些场景下(如交互式应用),需要立即发送数据:

c
// 禁用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的保活机制:

c
// 启用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回显服务器(带健壮的数据传输)

c
#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 带长度前缀的消息协议

c
#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后也关闭写端。

4/10基本的T
0%