Linux 网络编程复习总结

覆盖范围:协议与分层 → 数据封装与地址解析 → IP/TCP → B/S 与 C/S → 套接字核心函数 → TCP 客户端/服务器实战 → 信号量生产者消费者复习

一、协议与分层模型

1.1 什么是协议

协议是一组通信规则,约定双方"如何开始、如何表示数据、何时结束"。没有协议,两台主机即使物理连通,也无法理解彼此发送的比特流。

协议的三要素可以记为:语法(数据格式)、语义(每个字段的含义与应答动作)、时序(谁先发、什么时候发)。

1.2 OSI 七层与 TCP/IP 四层

OSI 是教学参考模型,TCP/IP 是实际落地模型。对应关系如下:

OSI 七层TCP/IP 四层代表协议
应用层 / 表示层 / 会话层应用层HTTP、FTP、SSH、Telnet
传输层传输层TCP、UDP
网络层网络层IP、ICMP、IGMP
数据链路层 / 物理层网络接口层(链路层)以太网帧、ARP

Linux 应用层网络编程主要工作在传输层及以下。调用 socket API 时,传输层的 TCP/UDP 头、网络层的 IP 头、链路层的帧头都由内核(含网卡驱动)处理,应用程序只面对字节流或数据报。

二、数据封装流程:从应用到比特流

数据不能"裸奔"在网络中传递,必须逐层加上头部。发送方向的封装顺序为:

应用数据 → 传输层(TCP/UDP 头)→ 网络层(IP 头)→ 链路层(以太网帧头 + 帧尾)→ 网卡发送

接收方向则反向逐层剥离头部,最终把应用数据交给对应的 socket 缓冲区。

两点补充:

  • 每层只负责加自己的头,接收端按相反顺序逐层剥掉,任何一层都不关心上层的载荷内容。
  • 以太网帧尾的 FCS 校验一般由网卡硬件计算并附加,软件构造帧时只管帧头。

三、以太网帧、ARP 与 IP 协议

3.1 以太网帧与 ARP 的分工

以太网帧:依据 MAC 地址在局域网内完成数据包的传输。

ARP(地址解析协议):已知 IP 地址,查询对应的 MAC 地址。只有拿到 MAC,以太网帧才能在链路层发出。

ARP 请求以广播方式发出(目的 MAC 全 F),目标主机收到后以单播应答;结果会存进本机的 ARP 缓存,并带老化时间,过期后重新查询。

3.2 IP 协议要点

IP 协议负责逻辑寻址和路由。IPv4 头部中需要关注的字段:

字段作用
版本IPv4 / IPv6
TTL数据包允许经过的路由跳数上限,每经一个路由减 1,减到 0 时丢弃,防止环路
源 IP / 目的 IP各 32 位(4 字节),点分十进制表示(如 192.168.1.108)

IP 地址标识主机,端口号标识该主机上的进程,二者合起来确定一个通信端点。严格地说,一条 TCP 连接由四元组唯一确定:源 IP、源端口、目的 IP、目的端口。服务器能用同一个 8080 端口同时服务多个客户端,靠的就是四元组不同。

四、TCP 协议核心特征

TCP 是面向连接的、可靠的、基于字节流的传输层协议,提供全双工通信。TCP 头部关键字段:

  • 16 位源端口号 / 16 位目的端口号:端口范围 0~65535,其中 0 保留,1~1023 是知名端口(绑定通常需要 root)。
  • 32 位序号 / 32 位确认序号:用于可靠传输、去重、按序重组。
  • 6 个标志位:SYN、ACK、FIN、RST、PSH、URG,控制连接的建立、拆除与数据传输。
  • 16 位窗口大小:流量控制,告知对方当前可接收的数据量。

TCP 是一对一的连接,天然属于 C/S 模型:服务器必须先存在并监听,客户端主动连接。也因为是字节流,TCP 不保留应用层的消息边界,一次 read 可能读到半条消息,也可能读到两条半,收发双方要自己约定切分方式。

五、B/S 与 C/S 模型比对

维度C/S(Client/Server)B/S(Browser/Server)
客户端需安装专用客户端程序只需浏览器,零维护
协议可自定义,灵活应用层只认 HTTP/HTTPS
响应速度快,可缓存大量数据相对较慢,不能缓存大量数据
安全性较高,可自定义加密,局域网内可控依赖 HTTPS 与浏览器安全模型
开发维护成本较高(两端都要维护)较低(仅维护服务端)
适用场景局域网、高性能交互广域网、跨平台通用访问

B/S 本质上是 C/S 的一种特化形态——客户端固定为浏览器,协议固定为 HTTP/HTTPS。

六、套接字(Socket)本质

套接字是一个文件描述符,指向内核中的一个套接字对象。该对象内部由两个缓冲区(发送缓冲区、接收缓冲区)实现双向通信。通信过程中,套接字一定是成对出现的:一端的发送缓冲区对应另一端的接收缓冲区。

所以对 socket 的 read/write 和读写普通文件很像,区别在于套接字不支持 lseek,没有文件偏移的概念;而且收发可以同时进行,两个方向互不影响。

七、网络字节序与地址转换函数

7.1 为什么需要字节序转换

主机字节序:x86 多为小端(低位字节存低地址)。网络字节序:TCP/IP 规定使用大端(高位字节存低地址)。因此,端口号和 IP 地址在写入 sockaddr_in 之前必须转为网络字节序。漏掉转换不会报错,但会连到一个莫名其妙的端口上,属于最难查的一类 bug。

7.2 字节序转换函数(<arpa/inet.h>)

函数命名规则:h = host,n = network,l = long(32 位,用于 IP),s = short(16 位,用于端口)。

uint32_t htonl(uint32_t hostlong);   // 主机字节序 32 位 → 网络字节序(IP)
uint16_t htons(uint16_t hostshort);  // 主机字节序 16 位 → 网络字节序(端口)
uint32_t ntohl(uint32_t netlong);    // 网络字节序 32 位 → 主机字节序(IP)
uint16_t ntohs(uint16_t netshort);   // 网络字节序 16 位 → 主机字节序(端口)

7.3 IP 地址转换函数:字符串 ↔ 二进制

inet_pton 和 inet_ntop 是可重入、同时支持 IPv4/IPv6 的现代接口,新代码优先用它们。
注释:p 代表的是 “Presentation to Network” 呈现给人看的形式,适合人阅读和书写的地址表示形式。
注释:n 代表的是 “Network” 是网络协议内部使用的二进制数值形式,并且是网络字节序

inet_pton:点分十进制字符串 → 网络字节序二进制

int inet_pton(int af, const char *src, void *dst);
参数说明
afAF_INET(IPv4)或 AF_INET6
src源字符串,如 "192.168.1.100"
dst转换结果写入的内存(IPv4 应传入 struct in_addr * 的地址)
返回值成功返回 1;格式无效返回 0;af 不支持返回 -1

注意返回值的三种情况:0 表示字符串不是合法地址,-1 表示地址族不支持,只有 1 才是成功。用 if (ret < 0) 判断会把格式错误漏掉。

inet_ntop:网络字节序二进制 → 点分十进制字符串

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
参数说明
afAF_INET / AF_INET6
src指向二进制 IP 的指针(如 &addr.sin_addr)
dst存放结果字符串的缓冲区
size缓冲区大小,IPv4 建议至少 INET_ADDRSTRLEN(16)
返回值成功返回 dst;失败返回 NULL

7.4 小例子:把转换结果打印成字节

光看函数说明不容易建立直觉,下面这段程序把三种表示的实际内存字节打出来。在小端机器上运行,能直接看到"主机序"和"网络序"的差别。

/* endian.c */
#include <arpa/inet.h>
#include <stdio.h>

static void dump(const char *tag, const void *p, size_t n)
{
    const unsigned char *b = p;
    size_t i;

    printf("%s:", tag);
    for (i = 0; i < n; i++)
        printf(" %02x", b[i]);
    printf("\n");
}

int main(void)
{
    uint16_t host_port = 8080;
    uint16_t net_port = htons(host_port);
    struct in_addr ip;
    char str[INET_ADDRSTRLEN];

    if (inet_pton(AF_INET, "192.168.1.108", &ip) != 1) {
        fprintf(stderr, "inet_pton failed\n");
        return 1;
    }

    dump("host order", &host_port, 2);   /* 小端机器:90 1f */
    dump("network order", &net_port, 2); /* 1f 90,即 0x1f90 = 8080 */
    dump("ip address", &ip, 4);          /* c0 a8 01 6c */

    inet_ntop(AF_INET, &ip, str, sizeof(str));
    printf("inet_ntop -> %s\n", str);
    return 0;
}

编译运行:

gcc -Wall -Wextra -o endian endian.c && ./endian

八、sockaddr 地址结构

8.1 通用结构 vs IPv4 专用结构

struct sockaddr 是通用"壳",struct sockaddr_in 是 IPv4 的实际填充结构。构造时用 sockaddr_in,传给函数时强制转换为 (struct sockaddr *),这样同一套函数签名可以兼容 IPv4、IPv6、Unix 域套接字。

struct sockaddr {
    sa_family_t sa_family;   // 地址族
    char        sa_data[14]; // 地址数据(协议相关)
};

struct sockaddr_in {
    sa_family_t    sin_family; // 地址族,IPv4 填 AF_INET
    in_port_t      sin_port;   // 端口号,必须是网络字节序
    struct in_addr sin_addr;   // IPv4 地址,必须是网络字节序
};

struct in_addr {
    uint32_t s_addr; // 32 位 IPv4 地址,网络字节序
};

8.2 关键字段使用规则

  • sin_family 不要做字节序转换,直接赋 AF_INET。
  • sin_port 必须经过 htons() 转换为网络字节序。
  • sin_addr.s_addr 必须经过 htonl() / inet_pton() 转换,或直接使用 INADDR_ANY。
  • 端口写 0 表示让内核分配一个临时端口,客户端通常不 bind,内核会自动做这件事。
  • 结构体里没有显式赋值的填充字节用 memset 清零,避免把栈上的垃圾值传给内核。

九、套接字核心函数详解

9.1 socket:创建套接字

#include <sys/socket.h>
int socket(int domain, int type, int protocol);
参数说明
domain协议族:AF_INET(IPv4)、AF_INET6、AF_UNIX 等
typeSOCK_STREAM(TCP,流式)、SOCK_DGRAM(UDP,报式)
protocol通常填 0,表示默认协议;也可显式填 IPPROTO_TCP / IPPROTO_UDP
返回值成功返回套接字文件描述符(非负);失败返回 -1,设置 errno

需要纠正一个常见说法:socket() 返回时只是创建了一个空的套接字对象,此时既没有本地 IP 和端口(除非之后 bind),也没有对端地址。执行 bind 后本地端点才确定;执行 connect 成功或 accept 返回后,描述符才与"本地 IP、本地端口、对端 IP、对端端口"这个四元组绑定。所以"描述符记录目的 IP 和端口"这句话只对已连接的套接字成立。

9.2 bind:绑定本地地址

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明
sockfdsocket() 返回的描述符
addr填充好的地址结构,需强转为 (const struct sockaddr *)
addrlen地址结构的大小,通常 sizeof(struct sockaddr_in)
返回值成功返回 0;失败返回 -1

服务器需要绑定一个众所周知的端口供客户端连接。若只关心端口、不关心具体网卡,sin_addr.s_addr 可设为 INADDR_ANY,表示绑定本机所有网卡地址。

失败最常见的原因是 EADDRINUSE:端口被占用,或者上一次运行留下的连接处于 TIME_WAIT。服务器加 SO_REUSEADDR 可以避开后一种情况。

9.3 listen:将套接字设为监听状态

int listen(int sockfd, int backlog);
参数说明
sockfd已 bind 的套接字描述符
backlog已完成连接队列(accept 队列)的长度上限
返回值成功返回 0;失败返回 -1

调用 listen 后,套接字从主动套接字变为被动套接字,内核开始接受该端口上的 SYN 请求,完成三次握手的连接进入已完成队列,等待 accept 取走。

关于 backlog 的两点澄清:

  • 它限制的是 accept 队列,不是"同时能有多少个客户端",更不是端口上能建立连接的总数。队列满了之后,新完成握手的连接可能被丢弃或忽略,客户端表现为连接慢、超时。
  • 内核实际取 min(backlog, /proc/sys/net/core/somaxconn),所以写 20 不保证真能排 20 个。想调大要先改 somaxconn。

9.4 accept:从已完成队列取出一个连接

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数说明
sockfd监听套接字描述符
addr传出参数:调用成功后,内核填入客户端的地址信息
addrlen传入传出参数:调用前设为 sizeof(struct sockaddr_in);调用后内核更新为实际写入的字节数
返回值成功返回新的已连接套接字描述符(用于与该客户端通信);失败返回 -1

accept 的关键理解:监听套接字不变,每接受一个客户端,就返回一个新的已连接描述符。服务器需要为每个客户端维护一个已连接描述符,而监听套接字继续留在 accept 循环中接待后续连接。addr 和 addrlen 都可以传 NULL,表示不关心对端地址;但只要想打印客户端 IP 和端口,就必须传。

9.5 connect:客户端发起连接

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明
sockfd客户端 socket() 返回的描述符
addr服务器的地址结构
addrlen地址结构大小
返回值成功返回 0;失败返回 -1

connect 触发 TCP 三次握手:客户端发送 SYN,服务器回应 SYN-ACK,客户端再回 ACK。三次握手完成后,connect 返回,双方进入 ESTABLISHED 状态。

常见的失败原因:ECONNREFUSED(服务器没启动、端口写错、被防火墙拒绝)、ETIMEDOUT(网络不通,SYN 没有回应)、ENETUNREACH(没有路由)。服务器必须先于客户端启动,否则客户端 connect 就会拿到 ECONNREFUSED。

9.6 数据收发与关闭

TCP 套接字可以直接使用文件 I/O 风格的 read / write,也可以使用 send / recv。

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

几个容易考也容易错的点:

  • read 返回 0 表示对端已关闭(收到了 FIN),不是读到空数据;返回 -1 才是出错,且要排除 errno == EINTR 的情况。
  • 返回值可能小于请求的长度。TCP 是字节流,一次 read 读多少由内核缓冲区里现有数据决定;要读满 n 字节,必须循环。
  • 向一个已经收到 RST 的连接 write,内核会给进程发 SIGPIPE,默认动作是终止进程。忽略它:signal(SIGPIPE, SIG_IGN),或者 send(..., MSG_NOSIGNAL)。

关闭时:

int close(int fd);                 // 关闭描述符,引用计数减到 0 时发送 FIN
int shutdown(int sock, int howto); // 半关闭,howto: SHUT_RD / SHUT_WR / SHUT_RDWR

close 只是把本进程的引用计数减一,减到 0 时内核才发 FIN,函数本身立刻返回。如果只想告诉对方"我不再发送了",但还要继续接收,用 shutdown(fd, SHUT_WR),它不受引用计数影响。

十、C/S 模型 TCP 通信流程

10.1 服务器端完整流程

  1. socket(AF_INET, SOCK_STREAM, 0) —— 创建 TCP 套接字。
  2. bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) —— 绑定 IP 和端口。
  3. listen(sockfd, backlog) —— 进入监听状态。
  4. accept(sockfd, (struct sockaddr *)&cli_addr, &addrlen) —— 阻塞等待客户端连接;返回新的已连接描述符 connfd。
  5. 使用 connfd 与客户端收发数据(read / write 或 recv / send)。
  6. close(connfd) 结束与该客户端的通信;监听套接字继续留在 accept 循环中。

服务器必须先于客户端启动,否则客户端 connect 会因连接被拒绝而失败。

10.2 客户端完整流程

  1. socket(AF_INET, SOCK_STREAM, 0) —— 创建 TCP 套接字。
  2. 填充 serv_addr:sin_family = AF_INET,sin_port = htons(port),sin_addr 设为服务器 IP(inet_pton 或 inet_addr)。
  3. connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) —— 发起连接,阻塞直到三次握手完成或失败。
  4. 使用 sockfd 收发数据。
  5. close(sockfd)。

inet_addr 是老接口,能用但有两个坑:输入非法时返回 INADDR_NONE,而广播地址 255.255.255.255 转换后也是 INADDR_NONE,没法区分;另外它不支持 IPv6。新代码统一用 inet_pton。

10.3 最小案例框架

下面是骨架,省略了返回值检查,完整可编译版本见 10.4 和 10.5。

服务器端(核心片段)

int sockfd = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in serv_addr = {0};
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8080);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
listen(sockfd, 20);

struct sockaddr_in cli_addr;
socklen_t addrlen = sizeof(cli_addr);
int connfd = accept(sockfd, (struct sockaddr *)&cli_addr, &addrlen);

char buf[1024];
ssize_t n = read(connfd, buf, sizeof(buf));
if (n > 0)
    write(connfd, "OK", 2);   // 这里回固定字符串;真实服务器一般原样回显收到的 n 个字节
close(connfd);
close(sockfd);

客户端(核心片段)

int sockfd = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in serv_addr = {0};
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);

connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));

write(sockfd, "hello", 5);
char buf[1024];
ssize_t n = read(sockfd, buf, sizeof(buf));
close(sockfd);

上述框架与标准 TCP 服务器/客户端创建步骤完全对应:服务器依次执行 socket → bind → listen → accept → 收发 → close,客户端依次执行 socket → connect → 收发 → close。

10.4 服务器完整示例(回显服务器)

在骨架上补齐三件事:检查每个返回值、允许重启时立即重新绑定端口、打印客户端地址。协议很简单:收到什么就回什么,对方关闭连接后回到 accept 等下一个客户端。

/* server.c —— 单连接回显服务器,编译:gcc -Wall -Wextra -o server server.c */
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

#define PORT      8080
#define BACKLOG   20
#define BUF_SIZE  1024

static void die(const char *msg)
{
    perror(msg);
    exit(EXIT_FAILURE);
}

/* write 可能一次写不完,循环直到写完或出错 */
static ssize_t writen(int fd, const char *buf, size_t n)
{
    size_t done = 0;

    while (done < n) {
        ssize_t w = write(fd, buf + done, n - done);
        // w 是返回已经写入的值。
        if (w < 0) {
            if (errno == EINTR)
                continue;
            return -1;
        }
        if (w == 0)
            return -1;   /* 防止死循环,实际可设 errno = EIO */
        done += (size_t)w;
    }
    return (ssize_t)n;
}

int main(void)
{
    int listen_fd, conn_fd;
    struct sockaddr_in serv_addr, cli_addr;
    socklen_t cli_len;
    char cli_ip[INET_ADDRSTRLEN];
    char buf[BUF_SIZE];
    ssize_t n;
    int on = 1;

    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0)
        die("socket");

    /* 重启服务器时端口可能还在 TIME_WAIT,加上这个选项可以立即重新 bind */
    if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) < 0)
        die("setsockopt");

    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(PORT);
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

    if (bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
        die("bind");

    if (listen(listen_fd, BACKLOG) < 0)
        die("listen");

    printf("server listening on 0.0.0.0:%d\n", PORT);

    for (;;) {
        cli_len = sizeof(cli_addr);
        conn_fd = accept(listen_fd, (struct sockaddr *)&cli_addr, &cli_len);
        if (conn_fd < 0) {
            if (errno == EINTR)   /* 被信号打断,重试即可 */
                continue;
            die("accept");
        }

        if (inet_ntop(AF_INET, &cli_addr.sin_addr, cli_ip, sizeof(cli_ip)) == NULL)
            snprintf(cli_ip, sizeof(cli_ip), "unknown");
        printf("accept %s:%d\n", cli_ip, ntohs(cli_addr.sin_port));

        while ((n = read(conn_fd, buf, sizeof(buf))) > 0) {
            if (writen(conn_fd, buf, (size_t)n) < 0) {
                perror("write");
                break;
            }
        }
        if (n < 0)
            perror("read");

        close(conn_fd);
        printf("close  %s:%d\n", cli_ip, ntohs(cli_addr.sin_port));
    }

    close(listen_fd);
    return 0;
}

10.5 客户端完整示例

客户端从标准输入读一行,发给服务器,再把回显打印出来。连上之后可以一直发,按 Ctrl-D 结束。

/* client.c —— 编译:gcc -Wall -Wextra -o client client.c */
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

#define PORT      8080
#define BUF_SIZE  1024

static void die(const char *msg)
{
    perror(msg);
    exit(EXIT_FAILURE);
}

int main(int argc, char *argv[])
{
    const char *ip = (argc > 1) ? argv[1] : "127.0.0.1";
    struct sockaddr_in serv_addr;
    char sendbuf[BUF_SIZE];
    char recvbuf[BUF_SIZE];
    ssize_t n;
    int fd;

    fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0)
        die("socket");

    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(PORT);

    /* inet_pton 成功返回 1,格式错误返回 0,地址族不支持返回 -1 */
    if (inet_pton(AF_INET, ip, &serv_addr.sin_addr) != 1) {
        fprintf(stderr, "invalid address: %s\n", ip);
        exit(EXIT_FAILURE);
    }

    if (connect(fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
        die("connect");

    printf("connected to %s:%d, 输入一行回车发送,Ctrl-D 退出\n", ip, PORT);

    while (fgets(sendbuf, sizeof(sendbuf), stdin) != NULL) {
        size_t len = strlen(sendbuf);

        if (send(fd, sendbuf, len, 0) < 0) {
            perror("send");
            break;
        }

        n = recv(fd, recvbuf, sizeof(recvbuf) - 1, 0);
        if (n < 0) {
            perror("recv");
            break;
        }
        if (n == 0) {
            printf("server closed\n");
            break;
        }
        recvbuf[n] = '\0';
        printf("echo: %s", recvbuf);
    }

    close(fd);
    return 0;
}

10.6 编译与运行

gcc -Wall -Wextra -O2 -o server server.c
gcc -Wall -Wextra -O2 -o client client.c

# 终端 1
./server

# 终端 2
./client              # 默认连 127.0.0.1:8080
./client 192.168.1.108

验证时可以对照以下几点:

  1. 先启动服务器再启动客户端;反过来启动,客户端会打印 connect: Connection refused。
  2. 客户端输入一行,服务器原样回显,两边都正常打印。
  3. 客户端按 Ctrl-D,服务器的 read 返回 0,打印 close 日志,回到 accept 等待下一个连接。
  4. ss -ltnp | grep 8080 可以看到监听套接字及其 accept 队列,ss -tnp 可以看到已建立的连接和四元组。

另外,accept 返回的 connfd 本地端口仍然是 8080,对端端口才是客户端那个随机端口。用 ss -tnp 看一条连接的两端,能直接对上这一点。

10.7 多客户端:每个连接一个子进程

10.4 的服务器一次只服务一个客户端,第二个人连上来要排队。改成 fork 之后,主进程只负责 accept,子进程负责收发。

/* server_fork.c —— 编译:gcc -Wall -Wextra -o server_fork server_fork.c */
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

#define PORT      8080
#define BACKLOG   20
#define BUF_SIZE  1024

static void die(const char *msg)
{
    perror(msg);
    exit(EXIT_FAILURE);
}

static ssize_t writen(int fd, const char *buf, size_t n)
{
    size_t left = n;

    while (left > 0) {
        ssize_t w = write(fd, buf, left);
        if (w < 0) {
            if (errno == EINTR)
                continue;
            return -1;
        }
        left -= (size_t)w;
        buf += w;
    }
    return (ssize_t)n;
}

/* 子进程里的全部工作:打印对端地址,然后把收到的内容回显回去 */
static void serve(int conn_fd)
{
    struct sockaddr_in peer;
    socklen_t len = sizeof(peer);
    char ip[INET_ADDRSTRLEN];
    char buf[BUF_SIZE];
    ssize_t n;

    if (getpeername(conn_fd, (struct sockaddr *)&peer, &len) == 0 &&
        inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip)) != NULL)
        printf("[pid %d] serve %s:%d\n", (int)getpid(), ip, ntohs(peer.sin_port));

    while ((n = read(conn_fd, buf, sizeof(buf))) > 0) {
        if (writen(conn_fd, buf, (size_t)n) < 0)
            break;
    }
}

int main(void)
{
    int listen_fd, conn_fd;
    struct sockaddr_in serv_addr, cli_addr;
    socklen_t cli_len;
    int on = 1;
    pid_t pid;

    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0)
        die("socket");

    if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) < 0)
        die("setsockopt");

    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(PORT);
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

    if (bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
        die("bind");

    if (listen(listen_fd, BACKLOG) < 0)
        die("listen");

    /* Linux 下把 SIGCHLD 设为忽略,子进程退出后由内核自动回收,不留僵尸进程。
       如果不这么写,就要在 SIGCHLD 处理函数里循环 waitpid(-1, NULL, WNOHANG)。 */
    signal(SIGCHLD, SIG_IGN);

    for (;;) {
        cli_len = sizeof(cli_addr);
        conn_fd = accept(listen_fd, (struct sockaddr *)&cli_addr, &cli_len);
        if (conn_fd < 0) {
            if (errno == EINTR)
                continue;
            die("accept");
        }

        pid = fork();
        if (pid < 0) {
            perror("fork");
            close(conn_fd);
            continue;           /* fork 失败不影响已有连接,继续接待下一个 */
        }
        if (pid == 0) {
            close(listen_fd);   /* 子进程不 accept,监听描述符用不上 */
            serve(conn_fd);     /* 子进程负责这条连接 */
            close(conn_fd);
            _exit(0);           /* 用 _exit 而不是 exit,避免刷新父进程的 stdio 缓冲区 */
        }

        close(conn_fd);         /* 父进程不参与通信,及时关掉,否则对端 EOF 收不到 */
    }

    close(listen_fd);
    return 0;
}

两个细节值得记:

  • 父进程 accept 之后必须 close(conn_fd)。描述符是引用计数,父进程不关,子进程关掉之后连接也不会真正释放,客户端会一直等不到 FIN。
  • 子进程要关掉 listen_fd。否则子进程退出前,监听套接字一直有引用,服务器重启会出现端口占用。

10.8 常见错误对照

现象原因
bind/listen 成功,客户端连不上绑定到了错误地址,或客户端 IP/端口写错;用 ss -ltn 核对监听地址
连接到一个奇怪端口忘了 htons,8080 被当成主机序解释
bind 返回 EADDRINUSE端口被占用,或旧连接处于 TIME_WAIT,加 SO_REUSEADDR
accept 之后服务器还收不到数据用 listen_fd 而不是 conn_fd 收发
一次 read 只读到半条消息TCP 是字节流,没有消息边界,需要自己定长度或分隔符
客户端断开后服务器进程被杀向已关闭的连接 write 触发 SIGPIPE,忽略它或改用 MSG_NOSIGNAL
服务器一重启就 bind 失败子进程没关 listen_fd,或缺少 SO_REUSEADDR
inet_addr 判断失败失效255.255.255.255 与错误返回值都是 INADDR_NONE,改用 inet_pton

附:函数速查

阶段服务器客户端
创建socket(AF_INET, SOCK_STREAM, 0)同左
地址bind 到 INADDR_ANY:portinet_pton + htons 填服务器地址
连接listen + accept 循环connect
收发read / write(或 recv / send)同左
关闭close(connfd),监听套接字保留close(fd)