一、POSIX IPC 概述

1.1 为什么要有 POSIX IPC?

System V IPC 存在诸多缺陷(用键而非文件名、无引用计数、接口复杂)。POSIX.1b 实时扩展重新设计了一套 IPC 机制,统称 POSIX IPC,包含三种:

机制 头文件 句柄类型 主要用途
消息队列 <mqueue.h> mqd_t 带优先级的消息传递
信号量 <semaphore.h> sem_t * 进程/线程同步
共享内存 <sys/mman.h> int(文件描述符) 高速数据共享

1.2 POSIX IPC 的统一模型

所有 POSIX IPC 对象都遵循与文件系统一致的操作模型:

命名(name)→ open → 使用 → close → unlink(引用计数归零时才真正删除)

对比 System V IPC:

键(key) → get → 使用 → ctl(IPC_RMID)(立即删除,无引用计数)

1.3 IPC 对象命名规则

POSIX IPC 对象用名字标识,SUSv3 规定的可移植格式为:

/myobject     (一个斜杠开头,后面不能再有斜杠)

在 Linux 上:

  • 共享内存和消息队列名字限长 255 字符(NAME_MAX
  • 信号量名字限长 251 字符(因为实现会自动加前缀 sem.

1.4 POSIX IPC 的主要优点

与 System V IPC 对比:

特性 System V IPC POSIX IPC
标识方式 整数键(key) 文件名式名字
引用计数 是(unlink 后等引用=0才删除)
接口一致性 较差(每种机制差异大) 好(统一 open/close/unlink 模式)
权限机制 类似文件,但有差异 与文件完全一致,支持 ACL
列出/删除 ipcs/ipcrm ls/rm(在虚拟文件系统上)
可移植性 更好(SUSv3 必选) 稍差(SUSv3 可选组件)

1.5 POSIX IPC 的持久性

POSIX IPC 具有内核持久性:创建后一直存在,直到显式 unlink 或系统重启。但与 System V 不同,unlink 后若仍有进程持有引用,对象不会立即消失——等所有引用关闭后才真正销毁。

1.6 编译链接

Linux 上使用 POSIX IPC 必须链接 librt

gcc -o myprogram myprogram.c -lrt
# 或 C++ 版本
g++ -o myprogram myprogram.cpp -lrt

二、POSIX 消息队列详解

2.1 与 System V 消息队列的关键区别


特性 System V 消息队列 POSIX 消息队列
消息选择 按整数类型(msgtyp),可选 FIFO/类型/优先队列 严格按优先级(数字越大越先收到)
引用计数
异步通知 是(mq_notify(),信号或线程)
与 select/poll 配合 不支持 Linux 上支持(fd 实现)
关联属性 msqid_ds mq_attr 结构体

2.2 创建/打开消息队列:mq_open()

#include <sys/stat.h>
#include <mqueue.h>    // mqd_t, mq_open, mq_attr
#include <fcntl.h>     // O_CREAT, O_RDONLY, O_WRONLY, O_RDWR
// 返回消息队列描述符,失败返回 (mqd_t)-1
mqd_t mq_open(const char *name, int oflag, ...
              /* mode_t mode, struct mq_attr *attr */);

oflag 常用标志:

标志 含义
O_CREAT 不存在则创建
O_EXCL 配合 O_CREAT,已存在则报错 EEXIST
O_RDONLY 只读(只能 mq_receive
O_WRONLY 只写(只能 mq_send
O_RDWR 读写
O_NONBLOCK 非阻塞模式(满/空时立即返回 EAGAIN

消息队列属性结构体:

struct mq_attr {
    long mq_flags;    // 描述符标志(0 或 O_NONBLOCK),mq_getattr/mq_setattr 用
    long mq_maxmsg;   // 队列最大消息数,创建时设置,之后不可改
    long mq_msgsize;  // 每条消息最大字节数,创建时设置,之后不可改
    long mq_curmsgs;  // 当前队列中的消息数,mq_getattr 返回
};

Linux 默认值:mq_maxmsg=10mq_msgsize=8192

2.3 消息队列描述符与内核数据结构的关系

类比文件描述符与文件的关系:

进程A                      系统全局表                  消息队列表
+----------+            +------------------+          +-----------+
| mqd 描述符 |  →→→→→→→→→ | 打开队列描述信息  | →→→→→→→→→ | 队列属性  |
| (进程私有) |            | (flags,指向队列) |          | 消息数据  |
+----------+            +------------------+          | 通知设置  |
                                                       +-----------+
进程B
+----------+
| mqd 描述符 |  →→→→→→ 也可以指向同一个队列描述 (fork后)
+----------+          或指向不同描述但同一队列 (各自mq_open后)

2.4 发送和接收消息

// 发送:msg_prio 越大优先级越高(0 最低)
int mq_send(mqd_t mqdes, const char *msg_ptr, size_t msg_len,
            unsigned int msg_prio);
// 接收:自动取出优先级最高的最老消息
// msg_len 必须 >= mq_msgsize,否则报 EMSGSIZE
ssize_t mq_receive(mqd_t mqdes, char *msg_ptr, size_t msg_len,
                   unsigned int *msg_prio);

消息在队列中的排列示意(按优先级降序):

队列头(先读)           队列尾(后读)
┌──────────────────────────────────────────┐
│ prio=10 │ prio=10 │ prio=5 │ prio=0     │
│  msg-c  │  msg-d  │ msg-a  │  msg-b     │
└──────────────────────────────────────────┘
  最先被读                        最后被读
  (同优先级内按 FIFO)

带超时版本:

// abs_timeout 是绝对时间(Epoch 起的秒+纳秒)
int mq_timedsend(mqd_t mqdes, const char *msg_ptr, size_t msg_len,
                 unsigned int msg_prio, const struct timespec *abs_timeout);
ssize_t mq_timedreceive(mqd_t mqdes, char *msg_ptr, size_t msg_len,
                        unsigned int *msg_prio, const struct timespec *abs_timeout);

2.5 完整可运行示例

// posix_mq_demo.cpp
// 演示 POSIX 消息队列的创建、发送、接收和删除
// 编译:g++ -o posix_mq_demo posix_mq_demo.cpp -lrt
// 运行:./posix_mq_demo
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <mqueue.h>
#include <fcntl.h>
#include <sys/stat.h>
int main() {
    const char *MQ_NAME = "/demo_mq";
    // 设置队列属性
    struct mq_attr attr;
    attr.mq_flags   = 0;      // 创建时此字段被忽略
    attr.mq_maxmsg  = 5;      // 队列最多5条消息
    attr.mq_msgsize = 256;    // 每条消息最大256字节
    attr.mq_curmsgs = 0;      // 创建时此字段被忽略
    // 创建消息队列(O_CREAT|O_EXCL 确保独占创建)
    // 权限 0600:只有所有者可读写
    mqd_t mqd = mq_open(MQ_NAME, O_CREAT | O_EXCL | O_RDWR, 0600, &attr);
    if (mqd == (mqd_t)-1) {
        perror("mq_open");
        return 1;
    }
    printf("消息队列创建成功,描述符: %d\n", (int)mqd);
    // 发送3条不同优先级的消息
    // 优先级数字越大越先被读取
    struct {
        const char *text;
        unsigned int prio;
    } messages[] = {
        {"低优先级消息", 0},
        {"中优先级消息", 5},
        {"高优先级消息", 10},
    };
    for (int i = 0; i < 3; i++) {
        if (mq_send(mqd, messages[i].text,
                    strlen(messages[i].text) + 1,
                    messages[i].prio) == -1) {
            perror("mq_send");
            return 1;
        }
        printf("已发送: \"%s\" (优先级=%u)\n",
               messages[i].text, messages[i].prio);
    }
    // 查询队列当前属性
    struct mq_attr curAttr;
    if (mq_getattr(mqd, &curAttr) == -1) {
        perror("mq_getattr");
        return 1;
    }
    printf("\n当前队列消息数: %ld\n\n", curAttr.mq_curmsgs);
    // 接收消息(按优先级从高到低)
    char buf[256];
    unsigned int prio;
    ssize_t n;
    while ((n = mq_receive(mqd, buf, sizeof(buf), &prio)) > 0) {
        printf("收到 (优先级=%u): \"%s\"\n", prio, buf);
    }
    // 队列空时 mq_receive 阻塞,此处需用非阻塞模式验证空队列
    // 改用 O_NONBLOCK 版本测试
    mqd_t mqd_nb = mq_open(MQ_NAME, O_RDONLY | O_NONBLOCK);
    n = mq_receive(mqd_nb, buf, sizeof(buf), &prio);
    if (n == -1 && errno == EAGAIN) {
        printf("\n队列已空(EAGAIN)\n");
    }
    mq_close(mqd_nb);
    // 关闭并删除消息队列
    mq_close(mqd);
    if (mq_unlink(MQ_NAME) == -1) {
        perror("mq_unlink");
        return 1;
    }
    printf("消息队列已删除\n");
    return 0;
}

预期输出:

消息队列创建成功,描述符: 3
已发送: "低优先级消息" (优先级=0)
已发送: "中优先级消息" (优先级=5)
已发送: "高优先级消息" (优先级=10)
当前队列消息数: 3
收到 (优先级=10): "高优先级消息"
收到 (优先级=5):  "中优先级消息"
收到 (优先级=0):  "低优先级消息"
队列已空(EAGAIN)
消息队列已删除

2.6 消息通知机制:mq_notify()

这是 POSIX 消息队列最独特的功能:当空队列收到新消息时,异步通知已注册的进程。

int mq_notify(mqd_t mqdes, const struct sigevent *notification);

通知方式由 sigevent 结构体的 sigev_notify 字段决定:

sigev_notify 值 通知方式
SIGEV_NONE 注册但不通知(仅占位,使其他进程无法注册)
SIGEV_SIGNAL 发送指定信号(sigev_signo)给进程
SIGEV_THREAD 在新线程中调用指定函数(sigev_notify_function

重要规则:

规则1:每个队列同时只能有一个进程注册通知
规则2:通知只在空队列收到消息时触发(非空队列→非空不触发)
规则3:通知触发一次后自动取消注册,需要再次 mq_notify() 重新注册
规则4:若有进程阻塞在 mq_receive(),通知不触发(那个进程直接收到消息)
规则5:mq_notify(mqdes, NULL) 可以主动取消注册

通知流程图:

进程调用 mq_notify 注册

队列当前有消息?

等待队列变空再来
新消息才会触发通知

等待新消息到达

有其他进程阻塞
在mq_receive?

那个进程直接收到消
息注册的进程不被通知

触发通知

注册自动取消

还需要继续通知?

必须再次调用
mq_notify重新注册

结束

方式一:通过信号通知(完整示例):

// 编译:g++ -o mq_notify_signal mq_notify_signal.cpp -lrt
// 运行:./mq_notify_signal /my_queue
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <signal.h>
#include <mqueue.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#define NOTIFY_SIG SIGUSR1
static void handler(int sig) { /* 只需中断 sigsuspend */ }
int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "用法: %s <队列名>\n", argv[0]);
        return 1;
    }
    mq_unlink(argv[1]);  // 清除残留队列
    // 创建队列
    struct mq_attr attr = {0, 5, 256, 0};
    mqd_t mqd = mq_open(argv[1], O_CREAT | O_RDONLY | O_NONBLOCK, 0600, &attr);
    if (mqd == (mqd_t)-1) { perror("mq_open"); return 1; }
    void *buffer = malloc(attr.mq_msgsize);
    // 阻塞 NOTIFY_SIG
    sigset_t blockMask, emptyMask;
    sigemptyset(&blockMask);
    sigaddset(&blockMask, NOTIFY_SIG);
    sigprocmask(SIG_BLOCK, &blockMask, NULL);
    // 安装信号处理函数
    struct sigaction sa = {};
    sa.sa_handler = handler;
    sigaction(NOTIFY_SIG, &sa, NULL);
    // 注册通知
    struct sigevent sev;
    sev.sigev_notify = SIGEV_SIGNAL;
    sev.sigev_signo  = NOTIFY_SIG;
    mq_notify(mqd, &sev);
    printf("已注册消息通知,等待消息...\n");
    sigemptyset(&emptyMask);
    // fork 子进程作为发送端
    pid_t pid = fork();
    if (pid == 0) {
        mqd_t send_mqd = mq_open(argv[1], O_WRONLY);
        const char *msgs[] = {"消息A", "消息B", "消息C"};
        for (int i = 0; i < 3; i++) {
            sleep(1);
            mq_send(send_mqd, msgs[i], strlen(msgs[i]) + 1, i);
            printf("[发送端] 发送: \"%s\"\n", msgs[i]);
        }
        mq_close(send_mqd);
        exit(0);
    }
    // 父进程接收 3 轮通知
    for (int rounds = 0; rounds < 3; ) {
        sigsuspend(&emptyMask);
        printf("收到通知信号!\n");
        mq_notify(mqd, &sev);  // 先重新注册
        ssize_t n;
        while ((n = mq_receive(mqd, (char*)buffer, attr.mq_msgsize, NULL)) >= 0)
            printf("  收到: \"%s\" (%ld 字节)\n", (char*)buffer, (long)n);
        if (errno != EAGAIN) { perror("mq_receive"); return 1; }
        printf("队列已排空\n\n");
        rounds++;
    }
    waitpid(pid, NULL, 0);
    free(buffer);
    mq_close(mqd);
    mq_unlink(argv[1]);
    printf("完成,队列已删除\n");
    return 0;
}

https://godbolt.org/z/sqjsMYP5z
方式二:通过新线程通知(核心逻辑):

// 编译:g++ -o mq_notify_thread mq_notify_thread.cpp -lrt -lpthread
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <mqueue.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <pthread.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
static mqd_t mqd;
static void notifySetup(mqd_t *mqdp);
static void threadFunc(union sigval sv) {
    mqd_t *mqdp = (mqd_t *)sv.sival_ptr;
    struct mq_attr attr;
    if (mq_getattr(*mqdp, &attr) == -1) { perror("mq_getattr"); return; }
    void *buffer = malloc(attr.mq_msgsize);
    if (!buffer) { perror("malloc"); return; }
    notifySetup(mqdp);  // 先重新注册,再排空
    ssize_t numRead;
    while ((numRead = mq_receive(*mqdp, (char*)buffer,
                                 attr.mq_msgsize, NULL)) >= 0) {
        printf("[通知线程] 收到: \"%s\" (%ld 字节)\n",
               (char*)buffer, (long)numRead);
    }
    if (errno != EAGAIN) perror("mq_receive");
    free(buffer);
    pthread_exit(NULL);
}
static void notifySetup(mqd_t *mqdp) {
    struct sigevent sev;
    sev.sigev_notify            = SIGEV_THREAD;
    sev.sigev_notify_function   = threadFunc;
    sev.sigev_notify_attributes = NULL;
    sev.sigev_value.sival_ptr   = mqdp;
    if (mq_notify(*mqdp, &sev) == -1) perror("mq_notify");
}
int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "用法: %s <队列名>\n", argv[0]);
        return 1;
    }
    mq_unlink(argv[1]);
    struct mq_attr attr = {0, 5, 256, 0};
    mqd = mq_open(argv[1], O_CREAT | O_RDONLY | O_NONBLOCK, 0600, &attr);
    if (mqd == (mqd_t)-1) { perror("mq_open"); return 1; }
    notifySetup(&mqd);
    printf("等待消息(通过线程通知)...\n");
    // fork 子进程发 3 条消息
    pid_t pid = fork();
    if (pid == 0) {
        mqd_t send_mqd = mq_open(argv[1], O_WRONLY);
        const char *msgs[] = {"消息A", "消息B", "消息C"};
        for (int i = 0; i < 3; i++) {
            sleep(1);
            mq_send(send_mqd, msgs[i], strlen(msgs[i]) + 1, i);
            printf("[发送端] 发送: \"%s\"\n", msgs[i]);
        }
        mq_close(send_mqd);
        exit(0);
    }
    waitpid(pid, NULL, 0);
    sleep(1);  // 等通知线程处理完
    mq_close(mqd);
    mq_unlink(argv[1]);
    printf("完成,队列已删除\n");
    return 0;
}

https://godbolt.org/z/GjPzes3cc

2.7 Linux 特有功能

在虚拟文件系统中查看队列:

# 需要先挂载消息队列文件系统(需要 root 权限)
mkdir /dev/mqueue
mount -t mqueue none /dev/mqueue
# 之后可以用普通命令操作
ls /dev/mqueue          # 列出所有消息队列
cat /dev/mqueue/myq     # 查看队列信息
rm /dev/mqueue/myq      # 删除队列(等价于 mq_unlink)

cat 一个队列文件会显示:

QSIZE:7    NOTIFY:0    SIGNO:0    NOTIFY_PID:0

其中:

  • QSIZE:队列中所有消息的总字节数
  • NOTIFY_PID:已注册通知的进程 ID(0 表示无)
  • NOTIFY:通知方式(0=信号,1=SIGEV_NONE,2=线程)
  • SIGNO:通知信号号码
    与 select/poll/epoll 配合:
    由于 Linux 用文件描述符实现消息队列描述符,可以直接对 mqd_t 使用 select()/poll()/epoll(),从而实现"同时等待消息队列和文件描述符"——这是 System V 消息队列做不到的。

2.8 消息队列系统限制


/proc 文件 含义 默认值 可修改上限
/proc/sys/fs/mqueue/msg_max mq_maxmsg 的上限 10 32768
/proc/sys/fs/mqueue/msgsize_max mq_msgsize 的上限 8192 1048576
/proc/sys/fs/mqueue/queues_max 系统最多多少个队列 256 INT_MAX

三、POSIX 信号量详解

3.1 两种类型

POSIX 信号量有两种:

命名信号量(Named Semaphore)
├── 有名字(如 /mysem)
├── 通过 sem_open() 访问
├── 任何有权限的进程都可以打开
└── 存在于 /dev/shm/sem.xxx(Linux 实现)
未命名信号量(Unnamed / Memory-based Semaphore)
├── 无名字,直接存在内存里
├── 通过 sem_init() 初始化
├── 线程共享:放在全局变量或堆上
└── 进程共享:放在共享内存(System V/POSIX/mmap)里

3.2 命名信号量 API

创建/打开:sem_open()
#include <sys/stat.h>
// 成功返回指向 sem_t 的指针,失败返回 SEM_FAILED
sem_t *sem_open(const char *name, int oflag, ...
                /* mode_t mode, unsigned int value */);
  • 若指定 O_CREAT:需要额外两个参数 mode(权限)和 value(初始值)
  • 创建和初始化是原子的(解决了 System V 信号量的竞争问题)
  • 返回的指针不能被拷贝后再使用(sem_t 内部可能含有指针等不可拷贝的内容)
    在 Linux 上,命名信号量存储在 /dev/shm/sem.<name> 文件中:
ls -l /dev/shm/sem.*
# -rw-rw---- 1 user group 16 ... /dev/shm/sem.demo
关闭:sem_close()
int sem_close(sem_t *sem);

只是解除本进程与信号量的关联,不删除信号量(类比 close() 文件)。进程终止或 exec() 时自动关闭。

删除:sem_unlink()
int sem_unlink(const char *name);

立即移除名字,等所有进程都 sem_close() 后才真正销毁(引用计数机制)。

3.3 信号量操作

等待(减1):sem_wait()
int sem_wait(sem_t *sem);      // 阻塞版本
int sem_trywait(sem_t *sem);   // 非阻塞版本,不能减则返回 EAGAIN
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);  // 超时版本

行为:

  • 若信号量值 > 0:立即减1,返回
  • 若信号量值 = 0:阻塞等待,直到值 > 0 再减1返回
  • 被信号中断时返回 EINTRSA_RESTART 不影响此行为)
发布(加1):sem_post()
int sem_post(sem_t *sem);

将信号量值加1,若有进程阻塞在 sem_wait(),唤醒其中一个(调度策略决定唤醒哪个)。
sem_post() 是信号安全的(async-signal-safe),可以在信号处理函数中调用,这是 mutex 做不到的。

获取当前值:sem_getvalue()
int sem_getvalue(sem_t *sem, int *sval);

返回当前值到 *sval。若有进程阻塞等待:Linux 返回 0,部分实现返回负数(绝对值=等待者数量)。注意:返回值可能在函数返回后立刻过时。

3.4 命名信号量完整示例

// posix_sem_demo.cpp
// 演示命名信号量:两个进程通过信号量同步对共享资源的访问
// 编译:g++ -o posix_sem_demo posix_sem_demo.cpp -lrt
// 运行:./posix_sem_demo
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <semaphore.h>
int main() {
    const char *SEM_NAME = "/demo_sem";
    // 创建信号量,初始值=1(表示资源可用,相当于互斥锁)
    // O_CREAT|O_EXCL:确保独占创建
    // 0600:只有所有者可读写
    // 初始值 1:可用
    sem_t *sem = sem_open(SEM_NAME, O_CREAT | O_EXCL, 0600, 1);
    if (sem == SEM_FAILED) {
        perror("sem_open");
        return 1;
    }
    printf("信号量创建成功,初始值=1\n");
    pid_t pid = fork();
    if (pid == -1) { perror("fork"); return 1; }
    if (pid == 0) {
        // 子进程
        printf("子进程 [%d] 尝试获取信号量...\n", getpid());
        // sem_wait:减1(获取资源)
        if (sem_wait(sem) == -1) { perror("sem_wait"); return 1; }
        printf("子进程 [%d] 获得信号量,使用资源中...\n", getpid());
        sleep(2);  // 模拟使用资源
        // sem_post:加1(释放资源)
        if (sem_post(sem) == -1) { perror("sem_post"); return 1; }
        printf("子进程 [%d] 释放信号量\n", getpid());
        // 关闭(不删除)
        sem_close(sem);
        return 0;
    } else {
        // 父进程:稍微延迟,让子进程先拿到信号量
        usleep(100000);  // 100ms
        printf("父进程 [%d] 尝试获取信号量...\n", getpid());
        int val;
        sem_getvalue(sem, &val);
        printf("父进程:当前信号量值=%d\n", val);
        // 阻塞等待,直到子进程释放
        if (sem_wait(sem) == -1) { perror("sem_wait"); return 1; }
        printf("父进程 [%d] 获得信号量\n", getpid());
        if (sem_post(sem) == -1) { perror("sem_post"); return 1; }
        wait(NULL);  // 等待子进程结束
        // 关闭并删除信号量
        sem_close(sem);
        if (sem_unlink(SEM_NAME) == -1) { perror("sem_unlink"); return 1; }
        printf("信号量已删除\n");
    }
    return 0;
}

3.5 未命名信号量

初始化:sem_init()
#include 
int sem_init(sem_t *sem, int pshared, unsigned int value);
  • pshared=0:线程间共享(sem 放全局变量或堆上即可)
  • pshared!=0:进程间共享(sem 必须放在共享内存里)

注意:不能对一个已初始化的信号量再次调用 sem_init()(行为未定义)

销毁:sem_destroy()
int sem_destroy(sem_t *sem);

销毁信号量前确保没有进程/线程在等待它。底层内存释放之前必须先 sem_destroy()

3.6 未命名信号量示例:线程互斥访问全局变量

// thread_sem_demo.cpp
// 用未命名信号量保护两个线程对全局变量的访问
// 编译:g++ -o thread_sem_demo thread_sem_demo.cpp -lpthread
// 注意:POSIX unnamed semaphore 不需要 -lrt(sem_init 在 libpthread 里)
#include <stdio.h>
#include <stdlib.h>
#include <semaphore.h>
#include <pthread.h>
static int   glob = 0;   // 被两个线程共享的全局变量
static sem_t sem;        // 未命名信号量,保护 glob
// 线程函数:循环 loops 次对 glob 做加1
static void *threadFunc(void *arg) {
    int loops = *((int *)arg);
    for (int j = 0; j < loops; j++) {
        // sem_wait:减1(相当于加锁)
        if (sem_wait(&sem) == -1) { perror("sem_wait"); return NULL; }
        // --- 临界区开始 ---
        int loc = glob;
        loc++;
        glob = loc;
        // --- 临界区结束 ---
        // sem_post:加1(相当于解锁)
        if (sem_post(&sem) == -1) { perror("sem_post"); return NULL; }
    }
    return NULL;
}
int main(int argc, char *argv[]) {
    int loops = (argc > 1) ? atoi(argv[1]) : 1000000;
    // 初始化未命名信号量
    // pshared=0:线程间共享
    // 初始值=1:相当于一把互斥锁(最多1个线程进入临界区)
    if (sem_init(&sem, 0, 1) == -1) { perror("sem_init"); return 1; }
    // 创建两个线程
    pthread_t t1, t2;
    if (pthread_create(&t1, NULL, threadFunc, &loops) != 0) { return 1; }
    if (pthread_create(&t2, NULL, threadFunc, &loops) != 0) { return 1; }
    // 等待两个线程结束
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    // 预期:glob == 2 * loops
    printf("glob = %d(预期 %d)\n", glob, 2 * loops);
    // 销毁信号量
    sem_destroy(&sem);
    return 0;
}

3.7 未命名信号量用于进程间(放在共享内存中)

// proc_sem_demo.cpp
// 进程共享未命名信号量(放在 mmap 匿名共享内存中)
// 编译:g++ -o proc_sem_demo proc_sem_demo.cpp -lpthread
#define _BSD_SOURCE
#include <stdio.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <semaphore.h>
// 定义共享内存区域的结构
struct SharedData {
    sem_t sem;      // 信号量放在共享内存里
    int   counter;  // 被保护的计数器
};
int main() {
    // 创建共享匿名映射,存放 SharedData
    SharedData *shared = (SharedData *)mmap(
        NULL, sizeof(SharedData),
        PROT_READ | PROT_WRITE,
        MAP_SHARED | MAP_ANONYMOUS,  // 父子进程共享,匿名映射
        -1, 0);
    if (shared == MAP_FAILED) { perror("mmap"); return 1; }
    shared->counter = 0;
    // 初始化进程共享信号量
    // pshared=1:进程间共享(必须放在共享内存中)
    // 初始值=1:互斥锁语义
    if (sem_init(&shared->sem, 1, 1) == -1) {
        perror("sem_init"); return 1;
    }
    pid_t pid = fork();
    if (pid == -1) { perror("fork"); return 1; }
    if (pid == 0) {
        // 子进程:加100次
        for (int i = 0; i < 100; i++) {
            sem_wait(&shared->sem);
            shared->counter++;
            sem_post(&shared->sem);
        }
        return 0;
    } else {
        // 父进程:加100次
        for (int i = 0; i < 100; i++) {
            sem_wait(&shared->sem);
            shared->counter++;
            sem_post(&shared->sem);
        }
        wait(NULL);
        printf("最终 counter = %d(预期 200)\n", shared->counter);
        // 销毁信号量,解除映射
        sem_destroy(&shared->sem);
        munmap(shared, sizeof(SharedData));
    }
    return 0;
}

四、System V 信号量 vs POSIX 信号量 vs Mutex 对比

线程间

进程间

需要原子操作多个

需要同步机制

线程间 还是 进程间?

需要信号处理函数中操作?

推荐 Pthreads Mutex所有权语义更安全

POSIX 未命名信号量sem_post 是信号安全的

需要名字/无关进程访问?

POSIX 命名信号量sem_open/sem_close/sem_unlink

POSIX 未命名信号量放在共享内存中

System V 信号量semop 可原子操作多个


特性 System V 信号量 POSIX 命名信号量 POSIX 未命名信号量 Mutex
接口复杂度
操作单位 集合(多个信号量原子操作) 单个 单个 单个
操作量 任意整数 ±1 ±1 锁/解锁
等待值为0 支持 不支持 不支持 不支持
引用计数 N/A N/A
初始化竞争 有(见第47章) 无(原子创建+初始化)
信号处理安全 sem_post 是 sem_post 是
所有权语义 有(只有锁者能解锁)
低竞争性能 差(每次都系统调用) 好(futex,无竞争不需系统调用)
可移植性 最好 较好(Linux 2.6+) 较好 最好

五、POSIX IPC 知识总图

POSIX IPC

消息队列mqueue.h

信号量semaphore.h

共享内存sys/mman.h

mq_openmq_closemq_unlink

mq_sendmq_receive

mq_notify异步通知

SIGEV_SIGNAL发信号

SIGEV_THREAD启新线程

命名信号量sem_open/close/unlink

未命名信号量sem_init/destroy

sem_waitsem_postsem_getvalue

shm_openshm_unlink

mmapmunmap

六、POSIX vs System V IPC 最终总结


对比维度 System V IPC POSIX IPC
标识方式 整数键(key) 文件名式名字(/xxx)
与文件系统一致性
引用计数/安全删除 无(需自己管理)
接口一致性 差(三种机制差异大) 好(统一 open/close/unlink)
列出/删除命令 ipcs / ipcrm ls / rm(在虚拟文件系统)
可移植性 好(SUSv3 必选) 稍差(可选组件)
特殊功能 信号量集原子操作
消息类型过滤
SEM_UNDO
消息优先级
异步通知(mq_notify)
与 epoll/select 配合
推荐场景 需要跨平台兼容 新代码,Linux 2.6+

Linux IPC 深度笔记:POSIX 共享内存、文件锁与套接字

本笔记涵盖三大主题:POSIX 共享内存(第54章)、文件锁(第55章)、套接字入门(第56章)。
力求用最直白的语言解释每一个概念,代码附有详细中文注释。

目录

  1. POSIX 共享内存
  2. 文件锁
  3. 套接字入门

1. POSIX 共享内存

1.1 为什么需要 POSIX 共享内存?

在此之前,进程间共享内存有两种方式:

方式 缺点
System V 共享内存 使用"键+标识符"机制,和 UNIX 标准 I/O(文件名+描述符)风格不一致,需要专门的系统调用
共享文件映射 必须在磁盘上创建文件,即使根本不需要持久化存储,也要承担磁盘 I/O 开销

POSIX 共享内存的优势:

  • 不需要磁盘文件,用内存文件系统(tmpfs,挂载在 /dev/shm
  • 使用文件描述符,可以复用已有的 fstat()fchmod() 等系统调用
  • 大小可以动态调整(通过 ftruncate()

1.2 Linux 实现细节

Linux 把 POSIX 共享内存对象存放在一个专用的 tmpfs 文件系统,挂载路径是 /dev/shm

  • 内核持久性:只要系统不重启,即使没有进程打开它,共享内存对象依然存在
  • 重启后消失:系统关机后丢失(和磁盘文件不同)
  • 容量限制:受 tmpfs 文件系统大小限制,默认通常是 256 MB
    超级用户可以用下面的命令重新挂载并修改大小:
mount -o remount,size=512m /dev/shm

1.3 使用步骤

使用 POSIX 共享内存只需两步:

步骤1:shm_open()  →  得到文件描述符 fd
步骤2:mmap(fd)    →  把共享内存映射到进程虚拟地址空间

这和普通文件的操作对比:

普通文件:open() → mmap()
共享内存:shm_open() → mmap()

两者几乎一致,区别在于 shm_open() 不会在磁盘上创建真实文件。

1.4 创建共享内存:shm_open()

#include <fcntl.h>
#include <sys/stat.h>
#include <sys/mman.h>
int shm_open(const char *name, int oflag, mode_t mode);
// 成功返回文件描述符,失败返回 -1

参数说明:

参数 含义
name 共享内存对象名,格式如 /myshm(以 / 开头的字符串)
oflag 标志位,控制打开行为
mode 创建新对象时的权限(同 open() 的 mode)

oflag 可以组合的标志:

标志 含义
O_CREAT 不存在则创建
O_EXCL O_CREAT 配合,确保只有自己创建(已存在则报错 EEXIST
O_RDONLY 只读打开
O_RDWR 读写打开
O_TRUNC 打开已有对象时截断为零长度

注意:新创建的共享内存对象初始长度为 0,必须调用 ftruncate() 设置大小后才能使用。

1.5 完整示例代码

创建共享内存对象
// 文件:pshm_create.cpp
// 功能:创建一个指定大小的 POSIX 共享内存对象
#include <sys/stat.h>   // S_IRUSR, S_IWUSR 等权限常量
#include <fcntl.h>      // O_RDWR, O_CREAT, O_EXCL
#include <sys/mman.h>   // shm_open(), mmap(), PROT_READ, PROT_WRITE, MAP_SHARED
#include <unistd.h>     // ftruncate(), close()
#include <cstdlib>      // atol(), exit()
#include <cstdio>       // fprintf(), perror()
#include <cstring>      // strcmp()
int main(int argc, char *argv[])
{
    // 检查命令行参数:至少需要 名称 和 大小
    if (argc < 3) {
        fprintf(stderr, "用法: %s [-cx] 共享内存名称 大小字节数\n", argv[0]);
        fprintf(stderr, "  -c  创建共享内存 (O_CREAT)\n");
        fprintf(stderr, "  -x  独占创建 (O_EXCL)\n");
        return 1;
    }
    int flags = O_RDWR;   // 默认:读写方式打开
    int opt;
    int nameIdx = 1;      // argv 中共享内存名称的下标
    // 解析可选参数 -c 和 -x
    for (int i = 1; i < argc && argv[i][0] == '-'; i++) {
        for (int j = 1; argv[i][j] != '\0'; j++) {
            if (argv[i][j] == 'c') {
                flags |= O_CREAT;   // 追加"不存在则创建"标志
            } else if (argv[i][j] == 'x') {
                flags |= O_EXCL;    // 追加"独占创建"标志
            }
        }
        nameIdx = i + 1;
    }
    if (nameIdx + 1 >= argc) {
        fprintf(stderr, "缺少参数\n");
        return 1;
    }
    const char *shmName = argv[nameIdx];         // 共享内存对象名,如 /demo_shm
    size_t size = (size_t)atol(argv[nameIdx + 1]); // 需要的字节数
    // 权限:仅所有者可读写(0600)
    mode_t perms = S_IRUSR | S_IWUSR;
    // ============================================================
    // 步骤1:调用 shm_open() 创建/打开共享内存对象
    // 类比:就像 open() 打开普通文件,只是底层存储在 /dev/shm/ 下
    // ============================================================
    int fd = shm_open(shmName, flags, perms);
    if (fd == -1) {
        perror("shm_open 失败");
        return 1;
    }
    // ============================================================
    // 步骤2:用 ftruncate() 设置共享内存的大小
    // 原因:新建的共享内存对象初始大小为 0,必须先调整大小
    // ============================================================
    if (ftruncate(fd, (off_t)size) == -1) {
        perror("ftruncate 失败");
        return 1;
    }
    // ============================================================
    // 步骤3:调用 mmap() 把共享内存映射到本进程的虚拟地址空间
    // - NULL:让内核自动选择映射地址
    // - PROT_READ | PROT_WRITE:允许读写
    // - MAP_SHARED:修改会同步到共享内存对象(对其他进程可见)
    // ============================================================
    void *addr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (addr == MAP_FAILED) {
        perror("mmap 失败");
        return 1;
    }
    // 映射成功后 fd 就不再需要了,可以关闭(映射仍然有效)
    close(fd);
    fprintf(stdout, "共享内存 %s 创建成功,大小 %zu 字节,映射地址 %p\n",
            shmName, size, addr);
    return 0;
}

编译和运行:

g++ pshm_create.cpp -o pshm_create -lrt
./pshm_create -c /demo_shm 10000
ls -l /dev/shm   # 可以看到 demo_shm 文件
写入共享内存
// 文件:pshm_write.cpp
// 功能:把命令行字符串写入已有的 POSIX 共享内存对象
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
int main(int argc, char *argv[])
{
    if (argc != 3) {
        fprintf(stderr, "用法: %s 共享内存名称 字符串内容\n", argv[0]);
        return 1;
    }
    const char *shmName = argv[1];
    const char *str     = argv[2];
    // 打开已有的共享内存对象(读写模式)
    // 注意:不带 O_CREAT,即不会创建新对象
    int fd = shm_open(shmName, O_RDWR, 0);
    if (fd == -1) {
        perror("shm_open 失败");
        return 1;
    }
    size_t len = strlen(str);   // 字符串长度(不含 \0)
    // 把共享内存调整为恰好能容纳字符串的大小
    if (ftruncate(fd, (off_t)len) == -1) {
        perror("ftruncate 失败");
        return 1;
    }
    fprintf(stdout, "调整大小为 %zu 字节\n", len);
    // 映射到本进程地址空间
    char *addr = (char *)mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (addr == MAP_FAILED) {
        perror("mmap 失败");
        return 1;
    }
    close(fd);  // 映射建立后即可关闭 fd
    // 把字符串复制进共享内存
    // 其他映射了同一对象的进程立即可见这份数据
    memcpy(addr, str, len);
    fprintf(stdout, "已写入 %zu 字节\n", len);
    return 0;
}
读取共享内存
// 文件:pshm_read.cpp
// 功能:读取并打印 POSIX 共享内存中的内容
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
int main(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "用法: %s 共享内存名称\n", argv[0]);
        return 1;
    }
    // 只读方式打开
    int fd = shm_open(argv[1], O_RDONLY, 0);
    if (fd == -1) {
        perror("shm_open 失败");
        return 1;
    }
    // 用 fstat() 获取共享内存当前大小
    // st_size 字段就是对象的字节数
    struct stat sb;
    if (fstat(fd, &sb) == -1) {
        perror("fstat 失败");
        return 1;
    }
    // 只读映射
    char *addr = (char *)mmap(NULL, (size_t)sb.st_size, PROT_READ, MAP_SHARED, fd, 0);
    if (addr == MAP_FAILED) {
        perror("mmap 失败");
        return 1;
    }
    close(fd);  // 不再需要文件描述符
    // 把共享内存内容写到标准输出
    write(STDOUT_FILENO, addr, (size_t)sb.st_size);
    write(STDOUT_FILENO, "\n", 1);
    return 0;
}
删除共享内存
// 文件:pshm_unlink.cpp
// 功能:删除 POSIX 共享内存对象
#include <sys/mman.h>
#include <cstdio>
int main(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "用法: %s 共享内存名称\n", argv[0]);
        return 1;
    }
    // shm_unlink() 类比 unlink(),删除共享内存对象的名字
    // 注意:已有映射不受影响,直到所有进程取消映射后对象才真正销毁
    if (shm_unlink(argv[1]) == -1) {
        perror("shm_unlink 失败");
        return 1;
    }
    fprintf(stdout, "已删除 %s\n", argv[1]);
    return 0;
}

1.6 各种共享内存方式对比


特性 System V 共享内存 共享文件映射 POSIX 共享内存
标识方式 键(key)+ 标识符(shmid) 文件路径 名称(/xxx
是否需要磁盘文件
大小可否动态调整 不可(创建时固定) 可(ftruncate) 可(ftruncate)
数据重启后持久
使用现有系统调用 否(需专用 shmctl 等) 是(fstat 等) 是(fstat 等)
可移植性 最广 广 较广

结论:新代码推荐优先选用 POSIX 共享内存(不需要持久化)或共享文件映射(需要持久化)。

2. 文件锁

2.1 为什么需要文件锁?

设想两个进程同时操作同一个"序列号文件":

文件内容初始值:1000
进程 A 步骤:读取(1000) → 使用 → 写入(1001)
进程 B 步骤:读取(1000) → 使用 → 写入(1001)

没有同步时,两个进程都读到 1000,最终文件写入 1001,但正确结果应该是 1002。这就是竞态条件(Race Condition)
文件锁的作用:让进程在操作文件前先"抢锁",操作完再"放锁",确保同一时刻只有一个进程修改数据。

2.2 flock():锁住整个文件

flock() 来自 BSD,最简单,只能锁住整个文件

#include <sys/file.h>
int flock(int fd, int operation);
// 成功返回 0,失败返回 -1

operation 值 含义
LOCK_SH 共享锁(读锁),多个进程可同时持有
LOCK_EX 独占锁(写锁),同一时刻只有一个进程可持有
LOCK_UN 解锁
LOCK_NB 非阻塞(与上面组合使用,获取失败立即返回而非阻塞)

锁兼容性规则:

当前持有 \ 新请求 LOCK_SH LOCK_EX
LOCK_SH(共享) 兼容(可以) 冲突(阻塞/失败)
LOCK_EX(独占) 冲突 冲突

flock() 示例代码:

// 文件:t_flock.cpp
// 功能:演示 flock() 文件锁的使用:锁文件 -> 睡眠 -> 解锁
#include <sys/file.h>   // flock(), LOCK_SH, LOCK_EX, LOCK_UN, LOCK_NB
#include <fcntl.h>      // open(), O_RDONLY
#include <unistd.h>     // sleep(), close()
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cerrno>
int main(int argc, char *argv[])
{
    if (argc < 3) {
        fprintf(stderr, "用法: %s 文件名 锁类型[n] [睡眠秒数]\n", argv[0]);
        fprintf(stderr, "  锁类型: s=共享锁, x=独占锁\n");
        fprintf(stderr, "  加 n 后缀表示非阻塞,如 sn 或 xn\n");
        return 1;
    }
    // 根据第二个参数决定锁类型
    int lockOp = (argv[2][0] == 's') ? LOCK_SH : LOCK_EX;
    // 如果第二个参数第二个字符是 'n',加上非阻塞标志
    if (argv[2][1] == 'n') {
        lockOp |= LOCK_NB;
    }
    // 以只读方式打开文件(flock 不要求写权限)
    int fd = open(argv[1], O_RDONLY);
    if (fd == -1) {
        perror("open 失败");
        return 1;
    }
    const char *lockName = (lockOp & LOCK_SH) ? "共享锁(LOCK_SH)" : "独占锁(LOCK_EX)";
    fprintf(stdout, "PID %ld: 正在请求 %s\n", (long)getpid(), lockName);
    // 尝试获取锁
    // 阻塞模式:其他进程持锁时会等待
    // 非阻塞模式(LOCK_NB):立即返回 -1 并设置 errno = EWOULDBLOCK
    if (flock(fd, lockOp) == -1) {
        if (errno == EWOULDBLOCK) {
            fprintf(stderr, "PID %ld: 锁已被占用,退出\n", (long)getpid());
        } else {
            perror("flock 失败");
        }
        return 1;
    }
    fprintf(stdout, "PID %ld: 获得 %s\n", (long)getpid(), lockName);
    // 持锁期间睡眠(模拟业务处理)
    int sleepSecs = (argc > 3) ? atoi(argv[3]) : 10;
    sleep(sleepSecs);
    fprintf(stdout, "PID %ld: 释放 %s\n", (long)getpid(), lockName);
    // 释放锁
    if (flock(fd, LOCK_UN) == -1) {
        perror("flock 解锁失败");
        return 1;
    }
    close(fd);
    return 0;
}
flock() 的锁继承与释放规则

关键点:flock() 的锁关联到"打开文件描述"(open file description),而非文件描述符本身。

进程内:
  fd1 = open("a.txt")    → 打开文件描述 A → 锁 L1
  fd2 = dup(fd1)         → 复制描述符,指向同一个打开文件描述 A → 共享锁 L1
  flock(fd2, LOCK_UN)    → 释放的是 L1,fd1 也失去了锁!
跨进程(fork):
  父进程 flock(fd, LOCK_EX)
  fork()
  子进程 flock(fd, LOCK_UN)  → 父进程的锁也被释放了!
flock() 的局限性
  1. 只能锁整个文件,粒度太粗
  2. 只有建议性锁(advisory),进程可以无视
  3. 很多 NFS 实现不支持 flock()

2.3 fcntl() 记录锁:精细控制

fcntl() 可以对文件的任意字节范围加锁,称为记录锁(Record Lock)

flock 结构体
struct flock {
    short l_type;    // 锁类型:F_RDLCK(读锁)/ F_WRLCK(写锁)/ F_UNLCK(解锁)
    short l_whence;  // 起始位置基准:SEEK_SET / SEEK_CUR / SEEK_END
    off_t l_start;   // 相对于 l_whence 的偏移量(字节)
    off_t l_len;     // 锁定的字节数;0 表示"到文件末尾"
    pid_t l_pid;     // 持锁进程 ID(仅 F_GETLK 时由内核填写)
};

锁的范围计算(类比 lseek()):
实际起始字节 = 基准位置 ( l_whence ) + l_start \text{实际起始字节} = \text{基准位置}(\text{l\_whence}) + \text{l\_start} 实际起始字节=基准位置(l_whence)+l_start
锁定范围 = [ 实际起始字节 ,  实际起始字节 + l_len − 1 ] \text{锁定范围} = [\text{实际起始字节},\ \text{实际起始字节} + \text{l\_len} - 1] 锁定范围=[实际起始字节, 实际起始字节+l_len1]
l_len = 0 \text{l\_len} = 0 l_len=0 时表示从起始字节一直锁到文件末尾,无论文件之后增长多少。

fcntl() 三种命令

cmd 含义
F_SETLK 非阻塞加锁/解锁;锁冲突时返回 -1(errno=EAGAIN 或 EACCES)
F_SETLKW 阻塞加锁;锁冲突时一直等待;检测到死锁时返回 -1(errno=EDEADLK)
F_GETLK 仅检测是否能加锁,不实际加锁;如能加锁,l_type 返回 F_UNLCK

记录锁示例代码
// 文件:region_locking.cpp
// 功能:封装 fcntl() 记录锁的三个常用函数
#include <fcntl.h>      // fcntl(), F_SETLK, F_SETLKW, F_GETLK
#include <unistd.h>
#include <sys/types.h>
#include <cstdio>
#include <cerrno>
// ---------------------------------------------------------------
// 内部辅助函数:统一设置 flock 结构并调用 fcntl()
// 参数:
//   fd     - 文件描述符
//   cmd    - F_SETLK / F_SETLKW / F_GETLK
//   type   - F_RDLCK / F_WRLCK / F_UNLCK
//   whence - SEEK_SET / SEEK_CUR / SEEK_END
//   start  - 相对 whence 的偏移(字节)
//   len    - 锁的字节数(0 表示到文件末尾)
// ---------------------------------------------------------------
static int lockReg(int fd, int cmd, int type,
                   int whence, off_t start, off_t len)
{
    struct flock fl;
    fl.l_type   = (short)type;
    fl.l_whence = (short)whence;
    fl.l_start  = start;
    fl.l_len    = len;
    fl.l_pid    = 0;   // F_GETLK 会由内核填写,这里不需要设置
    return fcntl(fd, cmd, &fl);
}
// ---------------------------------------------------------------
// 非阻塞加锁(F_SETLK)
// 如果锁冲突,立即返回 -1
// ---------------------------------------------------------------
int lockRegion(int fd, int type, int whence, off_t start, off_t len)
{
    return lockReg(fd, F_SETLK, type, whence, start, len);
}
// ---------------------------------------------------------------
// 阻塞加锁(F_SETLKW)
// 如果锁冲突,进程阻塞等待,直到获得锁或检测到死锁
// ---------------------------------------------------------------
int lockRegionWait(int fd, int type, int whence, off_t start, off_t len)
{
    return lockReg(fd, F_SETLKW, type, whence, start, len);
}
// ---------------------------------------------------------------
// 检测锁冲突(F_GETLK)
// 返回值:0 = 可以加锁;正整数 = 持有冲突锁的进程 PID;-1 = 出错
// ---------------------------------------------------------------
pid_t regionIsLocked(int fd, int type, int whence, off_t start, off_t len)
{
    struct flock fl;
    fl.l_type   = (short)type;
    fl.l_whence = (short)whence;
    fl.l_start  = start;
    fl.l_len    = len;
    fl.l_pid    = 0;
    if (fcntl(fd, F_GETLK, &fl) == -1) {
        return -1;  // fcntl 出错
    }
    // l_type == F_UNLCK 表示没有冲突,可以加锁
    return (fl.l_type == F_UNLCK) ? 0 : fl.l_pid;
}
// ---------------------------------------------------------------
// 演示主函数
// ---------------------------------------------------------------
int main(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "用法: %s 文件名\n", argv[0]);
        return 1;
    }
    int fd = open(argv[1], O_RDWR | O_CREAT, 0600);
    if (fd == -1) {
        perror("open 失败");
        return 1;
    }
    // 对文件第 0~99 字节加写锁(非阻塞)
    if (lockRegion(fd, F_WRLCK, SEEK_SET, 0, 100) == -1) {
        if (errno == EAGAIN || errno == EACCES) {
            fprintf(stderr, "字节 0-99 已被其他进程锁定\n");
        } else {
            perror("lockRegion 失败");
        }
        return 1;
    }
    fprintf(stdout, "成功获得字节 0-99 的写锁\n");
    // 检查字节 200-299 是否有冲突锁
    pid_t blocker = regionIsLocked(fd, F_WRLCK, SEEK_SET, 200, 100);
    if (blocker == 0) {
        fprintf(stdout, "字节 200-299 当前无冲突,可以加锁\n");
    } else if (blocker > 0) {
        fprintf(stdout, "字节 200-299 被进程 PID=%ld 锁定\n", (long)blocker);
    }
    // 解锁
    lockRegion(fd, F_UNLCK, SEEK_SET, 0, 100);
    fprintf(stdout, "已解锁字节 0-99\n");
    close(fd);
    return 0;
}

编译:

g++ region_locking.cpp -o region_locking

2.4 死锁检测

当两个进程互相等待对方持有的锁时,就发生了死锁(Deadlock)

进程 A              进程 B
持有字节 0-39 的写锁    持有字节 70-99 的写锁
等待字节 70-99 的写锁  等待字节 0-39 的写锁
        ↑                    ↓
        └────── 互相等待 ────┘
              死锁!

内核使用 F_SETLKW 时会自动检测死锁,选择一个进程返回错误 EDEADLK,打破僵局。
死锁情形用图表示:

时间线:
  t1: 进程A 请求写锁 [0,99]   → 成功
  t2: 进程B 请求写锁 [50,149] → 阻塞(与A冲突)
  t3: 进程A 请求写锁 [50,149] → 阻塞(与B冲突)
      ↓
  内核检测到死锁,选择A(或B)返回 EDEADLK

2.5 PID 锁文件:确保程序单实例运行

很多守护进程(daemon)用文件锁确保只运行一个实例:

// 文件:create_pid_file.cpp
// 功能:创建 PID 锁文件,确保程序只有一个实例在运行
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cerrno>
// 创建/打开 PID 文件并加写锁
// 返回文件描述符(成功),或 -1(已有实例在运行)
int createPidFile(const char *progName, const char *pidFile)
{
    // 打开(或创建)PID 文件,读写模式
    int fd = open(pidFile, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
    if (fd == -1) {
        fprintf(stderr, "%s: 无法打开 PID 文件 %s: %s\n",
                progName, pidFile, strerror(errno));
        return -1;
    }
    // 对整个文件加写锁(非阻塞)
    // F_SETLK + F_WRLCK:如果已有进程持有锁,立即返回失败
    struct flock fl;
    fl.l_type   = F_WRLCK;  // 写锁(独占)
    fl.l_whence = SEEK_SET;
    fl.l_start  = 0;
    fl.l_len    = 0;         // 0 = 锁整个文件
    if (fcntl(fd, F_SETLK, &fl) == -1) {
        if (errno == EAGAIN || errno == EACCES) {
            // 锁已被占用,说明另一个实例正在运行
            fprintf(stderr, "%s: PID 文件 '%s' 已被锁定,程序可能已在运行\n",
                    progName, pidFile);
        } else {
            fprintf(stderr, "%s: fcntl 失败: %s\n", progName, strerror(errno));
        }
        close(fd);
        return -1;
    }
    // 清空文件(防止上次遗留的 PID 字符串更长导致残留)
    if (ftruncate(fd, 0) == -1) {
        fprintf(stderr, "ftruncate 失败: %s\n", strerror(errno));
        close(fd);
        return -1;
    }
    // 把当前进程的 PID 写入文件
    char buf[32];
    int n = snprintf(buf, sizeof(buf), "%ld\n", (long)getpid());
    if (write(fd, buf, (size_t)n) != n) {
        fprintf(stderr, "写入 PID 失败\n");
        close(fd);
        return -1;
    }
    // 返回 fd,调用者持有它(持有 fd 就持有锁)
    return fd;
}
int main(int argc, char *argv[])
{
    const char *pidFile = "/tmp/my_daemon.pid";
    int fd = createPidFile(argv[0], pidFile);
    if (fd == -1) {
        fprintf(stderr, "无法启动,已有实例在运行或发生错误\n");
        return 1;
    }
    fprintf(stdout, "程序启动,PID=%ld,锁文件=%s\n", (long)getpid(), pidFile);
    fprintf(stdout, "按回车键退出并释放锁...\n");
    getchar();
    // 程序退出时 fd 自动关闭,锁自动释放,但建议手动清理文件
    close(fd);
    unlink(pidFile);
    return 0;
}

2.6 flock() vs fcntl() 对比


特性 flock() fcntl()
锁粒度 整个文件 任意字节范围
锁类型 共享/独占 读锁/写锁
死锁检测 有(EDEADLK)
fork() 后继承 子进程共享同一把锁 子进程不继承锁
关闭任意 fd 时 仅当所有共享该锁的 fd 都关闭才释放 关闭任一指向该文件的 fd 就释放所有锁
来源 BSD System V
SUSv3 标准 未标准化 标准化
NFS 支持 部分支持 支持

2.7 /proc/locks 查看系统锁状态

cat /proc/locks
# 输出示例:
# 1: POSIX  ADVISORY  WRITE 458 03:07:133880 0 EOF
# 2: FLOCK  ADVISORY  WRITE 404 03:07:133875 0 EOF

字段含义(从左到右):

  1. 锁序号
  2. 类型:POSIX(fcntl 锁)或 FLOCK(flock 锁)
  3. 模式:ADVISORY(建议性)或 MANDATORY(强制性)
  4. 锁类型:READWRITE
  5. 持锁进程 PID
  6. 主设备号:次设备号:inode号(标识文件)
  7. 锁起始字节
  8. 锁结束字节(EOF 表示到文件末尾)

3. 套接字入门

3.1 套接字是什么?

套接字(Socket)是进程间通信(IPC)的一种方式,特别之处在于:

  • 可以在同一台机器上的进程间通信
  • 也可以在网络上不同机器的进程间通信
    类比:如果说管道(pipe)是同一栋楼里两个房间的内部电话,那么套接字就是可以打国际长途的电话系统。

3.2 通信域(Domain)

套接字存在于某个"通信域",决定了地址格式和通信范围:

常量 通信范围 地址格式
UNIX 域 AF_UNIX 同一台主机 文件系统路径名
IPv4 域 AF_INET 通过 IPv4 网络 32位IP地址 + 16位端口号
IPv6 域 AF_INET6 通过 IPv6 网络 128位IP地址 + 16位端口号

3.3 套接字类型


类型 常量 特点
流套接字 SOCK_STREAM 可靠、双向、字节流、面向连接(TCP)
数据报套接字 SOCK_DGRAM 不可靠、无连接、保留消息边界(UDP)

流套接字:类比打电话——先拨号建立连接,然后双向通话,挂断后连接结束。
数据报套接字:类比寄信——每封信独立投递,可能丢失、乱序,但每封信是完整的一个单元。

3.4 套接字系统调用概览

socket()   - 创建套接字
bind()     - 绑定套接字到地址(服务器用)
listen()   - 监听连接(流套接字服务器用)
accept()   - 接受连接(返回新的已连接套接字)
connect()  - 发起连接(客户端用)
send()/write()   - 发送数据
recv()/read()    - 接收数据
close()    - 关闭套接字

3.5 流套接字通信流程

服务器端                        客户端
  |                               |
  | socket()                      | socket()
  |                               |
  | bind()                        |
  | (绑定到已知地址)             |
  |                               |
  | listen()                      |
  | (开始监听连接)               |
  |                               | connect()
  |<──────── 连接请求 ────────────|
  |                               |
  | accept()                      |
  | (返回新fd,专门与此客户端通信)|
  |                               |
  |<═══════ 双向数据传输 ═════════>|
  |  read()/write()               | read()/write()
  |                               |
  | close()                       | close()

3.6 数据报套接字通信流程

服务器端                        客户端
  |                               |
  | socket()                      | socket()
  |                               |
  | bind()                        |
  | (绑定到已知地址)             |
  |                               |
  | recvfrom()                    | sendto(服务器地址)
  |<──────── 数据报 ─────────────|
  |                               |
  | sendto(客户端地址)            | recvfrom()
  |──────── 响应数据报 ──────────>|
  |                               |
  | close()                       | close()

3.7 核心系统调用说明

socket() 创建套接字
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
// 成功返回文件描述符(sockfd),失败返回 -1
  • domainAF_UNIX / AF_INET / AF_INET6
  • typeSOCK_STREAMSOCK_DGRAM
  • protocol:通常填 0(自动选择)
bind() 绑定地址
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

服务器调用此函数,把套接字绑定到一个众所周知的地址,让客户端能找到它。

listen() 监听连接
int listen(int sockfd, int backlog);
  • backlog:待处理连接请求的最大队列长度
  • 调用后套接字变为"被动套接字",等待 accept()
accept() 接受连接
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// 返回新的已连接套接字的文件描述符

关键理解:accept() 创建一个全新的套接字用于与这个客户端通信,原来的监听套接字继续监听新连接。

connect() 发起连接
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

客户端调用,向服务器的已知地址发起连接请求。

3.8 通用套接字地址结构

不同域的地址格式不同,为了让 bind()connect() 等系统调用能统一接受各种地址,定义了通用结构 sockaddr

struct sockaddr {
    sa_family_t sa_family;  // 地址族(AF_UNIX / AF_INET 等)
    char        sa_data[14]; // 实际地址内容(大小因域而异)
};

使用时,把具体的地址结构(如 sockaddr_in强制转型sockaddr * 传入系统调用:

struct sockaddr_in serverAddr;
// ... 填写 serverAddr ...
bind(sockfd, (struct sockaddr *)&serverAddr, sizeof(serverAddr));

3.9 完整示例:TCP 回显服务器和客户端

// 文件:tcp_server.cpp
// 功能:TCP 流套接字服务器,接收客户端发来的字符串并原样返回(回显)
#include <sys/socket.h>  // socket(), bind(), listen(), accept()
#include <netinet/in.h>  // sockaddr_in, INADDR_ANY
#include <arpa/inet.h>   // htons(), htonl()
#include <unistd.h>      // read(), write(), close()
#include <cstdio>
#include <cstdlib>
#include <cstring>
#define PORT    8080    // 服务器监听端口
#define BACKLOG 10      // 最大待处理连接队列长度
#define BUF_SIZE 1024
int main()
{
    // ============================================================
    // 第1步:创建流套接字
    // AF_INET:IPv4 网络
    // SOCK_STREAM:TCP(可靠字节流)
    // ============================================================
    int listenFd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenFd == -1) {
        perror("socket 创建失败");
        return 1;
    }
    // 允许端口复用(避免重启程序时出现"Address already in use")
    int optval = 1;
    setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
    // ============================================================
    // 第2步:绑定地址
    // INADDR_ANY:监听所有网络接口
    // htons(PORT):把端口号从主机字节序转换为网络字节序(大端)
    // ============================================================
    struct sockaddr_in serverAddr;
    memset(&serverAddr, 0, sizeof(serverAddr));
    serverAddr.sin_family      = AF_INET;          // IPv4
    serverAddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
    serverAddr.sin_port        = htons(PORT);       // 端口 8080
    if (bind(listenFd, (struct sockaddr *)&serverAddr, sizeof(serverAddr)) == -1) {
        perror("bind 失败");
        return 1;
    }
    // ============================================================
    // 第3步:开始监听,等待客户端连接
    // ============================================================
    if (listen(listenFd, BACKLOG) == -1) {
        perror("listen 失败");
        return 1;
    }
    fprintf(stdout, "服务器启动,监听端口 %d\n", PORT);
    // 循环处理客户端连接
    for (;;) {
        // ============================================================
        // 第4步:接受客户端连接
        // accept() 阻塞直到有客户端连接
        // 返回一个新的 fd(connFd)专门用于和这个客户端通信
        // listenFd 继续用于接受新连接
        // ============================================================
        struct sockaddr_in clientAddr;
        socklen_t clientLen = sizeof(clientAddr);
        int connFd = accept(listenFd, (struct sockaddr *)&clientAddr, &clientLen);
        if (connFd == -1) {
            perror("accept 失败");
            continue;
        }
        // 打印客户端 IP 和端口
        char clientIP[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &clientAddr.sin_addr, clientIP, sizeof(clientIP));
        fprintf(stdout, "客户端连接:%s:%d\n", clientIP, ntohs(clientAddr.sin_port));
        // ============================================================
        // 第5步:收发数据(回显:收到什么就发回去)
        // ============================================================
        char buf[BUF_SIZE];
        ssize_t nRead;
        while ((nRead = read(connFd, buf, sizeof(buf))) > 0) {
            // 把收到的数据原样发回
            if (write(connFd, buf, (size_t)nRead) != nRead) {
                fprintf(stderr, "write 不完整\n");
                break;
            }
        }
        if (nRead == -1) {
            perror("read 失败");
        }
        fprintf(stdout, "客户端断开连接\n");
        close(connFd);  // 关闭与该客户端的连接(listenFd 不关闭,继续监听)
    }
    close(listenFd);
    return 0;
}
// 文件:tcp_client.cpp
// 功能:TCP 流套接字客户端,发送字符串给服务器并打印回显结果
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#define SERVER_IP "127.0.0.1"  // 连接本机服务器
#define PORT 8080
#define BUF_SIZE 1024
int main(int argc, char *argv[])
{
    if (argc < 2) {
        fprintf(stderr, "用法: %s 要发送的消息\n", argv[0]);
        return 1;
    }
    // ============================================================
    // 第1步:创建套接字(客户端也需要)
    // ============================================================
    int sockFd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockFd == -1) {
        perror("socket 失败");
        return 1;
    }
    // ============================================================
    // 第2步:连接服务器
    // connect() 发起 TCP 三次握手
    // ============================================================
    struct sockaddr_in serverAddr;
    memset(&serverAddr, 0, sizeof(serverAddr));
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_port   = htons(PORT);
    // 把点分十进制 IP 字符串转换为网络字节序的二进制
    if (inet_pton(AF_INET, SERVER_IP, &serverAddr.sin_addr) <= 0) {
        perror("inet_pton 失败");
        return 1;
    }
    if (connect(sockFd, (struct sockaddr *)&serverAddr, sizeof(serverAddr)) == -1) {
        perror("connect 失败");
        return 1;
    }
    fprintf(stdout, "已连接到服务器 %s:%d\n", SERVER_IP, PORT);
    // ============================================================
    // 第3步:发送消息
    // ============================================================
    const char *msg = argv[1];
    size_t msgLen = strlen(msg);
    if (write(sockFd, msg, msgLen) != (ssize_t)msgLen) {
        perror("write 失败");
        return 1;
    }
    fprintf(stdout, "已发送:%s\n", msg);
    // ============================================================
    // 第4步:接收服务器的回显
    // ============================================================
    char buf[BUF_SIZE];
    ssize_t nRead = read(sockFd, buf, sizeof(buf) - 1);
    if (nRead == -1) {
        perror("read 失败");
        return 1;
    }
    buf[nRead] = '\0';
    fprintf(stdout, "服务器回显:%s\n", buf);
    close(sockFd);
    return 0;
}

编译和运行:

# 编译
g++ tcp_server.cpp -o tcp_server
g++ tcp_client.cpp -o tcp_client
# 在终端1启动服务器
./tcp_server
# 在终端2运行客户端
./tcp_client "Hello, World!"
# 输出:服务器回显:Hello, World!

3.10 UDP 数据报套接字示例

// 文件:udp_server.cpp
// 功能:UDP 数据报套接字服务器,收到消息后回显
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>
#define PORT 9090
#define BUF_SIZE 1024
int main()
{
    // UDP:SOCK_DGRAM(无连接)
    int sockFd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockFd == -1) { perror("socket"); return 1; }
    struct sockaddr_in serverAddr;
    memset(&serverAddr, 0, sizeof(serverAddr));
    serverAddr.sin_family      = AF_INET;
    serverAddr.sin_addr.s_addr = htonl(INADDR_ANY);
    serverAddr.sin_port        = htons(PORT);
    if (bind(sockFd, (struct sockaddr *)&serverAddr, sizeof(serverAddr)) == -1) {
        perror("bind"); return 1;
    }
    fprintf(stdout, "UDP 服务器监听端口 %d\n", PORT);
    char buf[BUF_SIZE];
    for (;;) {
        struct sockaddr_in clientAddr;
        socklen_t clientLen = sizeof(clientAddr);
        // recvfrom() 接收数据报,同时获取发送方地址
        // 注意:UDP 不需要 accept(),直接接收即可
        ssize_t nRead = recvfrom(sockFd, buf, sizeof(buf) - 1, 0,
                                  (struct sockaddr *)&clientAddr, &clientLen);
        if (nRead == -1) { perror("recvfrom"); continue; }
        buf[nRead] = '\0';
        char clientIP[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &clientAddr.sin_addr, clientIP, sizeof(clientIP));
        fprintf(stdout, "收到来自 %s:%d 的消息:%s\n",
                clientIP, ntohs(clientAddr.sin_port), buf);
        // sendto() 把数据报发送到指定地址(不需要建立连接)
        sendto(sockFd, buf, (size_t)nRead, 0,
               (struct sockaddr *)&clientAddr, clientLen);
    }
    close(sockFd);
    return 0;
}

3.11 流套接字 vs 数据报套接字


对比项 流套接字(SOCK_STREAM/TCP) 数据报套接字(SOCK_DGRAM/UDP)
可靠性 保证数据到达 可能丢失
消息边界 无(字节流) 有(每次 recvfrom 收一条消息)
顺序 保证有序 可能乱序
连接 面向连接(需要三次握手) 无连接
类比 打电话 寄信
协议 TCP UDP
适用场景 HTTP、FTP、SSH 等 DNS、视频流、游戏等

总结

三个主题的核心思路:
POSIX 共享内存:用 shm_open() 替代 open(),得到文件描述符后用 mmap() 映射,无需磁盘文件即可在进程间共享内存区域。记住新建对象初始大小为 0,必须先 ftruncate()
文件锁flock() 锁整个文件,简单但粒度粗;fcntl() 锁任意字节范围,支持死锁检测,是标准做法。锁的继承和释放语义是重点,特别是 fcntl() 的"关闭任意指向该文件的 fd 就释放所有锁"的坑。
套接字:理解通信域(AF_UNIX/AF_INET)、套接字类型(SOCK_STREAM/SOCK_DGRAM),以及流套接字的 socket→bind→listen→accept 服务器流程和 socket→connect 客户端流程。数据报套接字不需要 connect/accept,直接 sendto/recvfrom。

Linux 套接字深度笔记:UNIX域、TCP/IP基础、Internet域、服务器设计

涵盖第57~60章:UNIX域套接字、TCP/IP网络基础、Internet域套接字、服务器设计模式。

目录

  1. UNIX域套接字(第57章)
  2. TCP/IP网络基础(第58章)
  3. Internet域套接字(第59章)
  4. 服务器设计(第60章)

1. UNIX域套接字

1.1 概述

UNIX域套接字专门用于同一台机器上进程间的通信。它和 Internet 套接字的用法几乎一样,只是:

  • 地址不是 IP + 端口,而是文件系统中的路径名
  • 通信在内核内部完成,不走网络协议栈,速度更快
  • 可以用文件权限控制访问

1.2 地址结构 sockaddr_un

struct sockaddr_un {
    sa_family_t sun_family;   // 始终是 AF_UNIX
    char sun_path[108];       // 套接字路径名(含终止 \0)
};

sun_ 前缀来自 “socket unix”,与 Sun Microsystems 无关。
sun_path 最大 108 字节(不同实现不同,HP-UX 只有 92 字节),移植性代码应按 92 字节来限制。
绑定 UNIX 域套接字的标准做法:

// 代码片段:绑定 UNIX 域套接字到路径名
struct sockaddr_un addr;
memset(&addr, 0, sizeof(struct sockaddr_un));  // 清零整个结构体(含非标准字段)
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, "/tmp/mysock", sizeof(addr.sun_path) - 1);
// sizeof - 1 配合前面的 memset 保证末尾有 \0
bind(sfd, (struct sockaddr *)&addr, sizeof(struct sockaddr_un));

注意事项:

  • 调用 bind() 会在文件系统中创建一个特殊文件(类型 sls -l 第一列显示 s
  • 不能绑定到已存在的路径(否则报 EADDRINUSE
  • 建议用绝对路径,避免依赖当前工作目录
  • 不再需要时,应用 unlink() 删除路径文件
  • 不能用 open() 打开套接字文件

1.3 UNIX域流套接字示例(服务器+客户端)

这对程序实现了一个"数据中转"功能:客户端把标准输入发给服务器,服务器把收到的数据写到标准输出。

服务器端
// 文件:us_xfr_sv.cpp
// 功能:UNIX域流套接字服务器,把客户端发来的数据转发到标准输出
// 编译:g++ us_xfr_sv.cpp -o us_xfr_sv
#include <sys/un.h>       // sockaddr_un
#include <sys/socket.h>   // socket(), bind(), listen(), accept()
#include <unistd.h>       // read(), write(), close(), unlink()
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cerrno>
#define SV_SOCK_PATH "/tmp/us_xfr"  // 服务器套接字路径
#define BUF_SIZE 100
#define BACKLOG 5                   // 待处理连接队列长度
int main()
{
    // ============================================================
    // 1. 创建 UNIX 域流套接字
    // ============================================================
    int sfd = socket(AF_UNIX, SOCK_STREAM, 0);
    if (sfd == -1) { perror("socket"); return 1; }
    // ============================================================
    // 2. 删除可能存在的旧套接字文件
    //    原因:bind() 不允许绑定已存在的路径
    //    如果程序上次异常退出没有清理,就会残留这个文件
    // ============================================================
    if (remove(SV_SOCK_PATH) == -1 && errno != ENOENT) {
        // ENOENT = 文件不存在,这是正常情况,不报错
        perror("remove"); return 1;
    }
    // ============================================================
    // 3. 绑定套接字到路径名
    // ============================================================
    struct sockaddr_un addr;
    memset(&addr, 0, sizeof(struct sockaddr_un));
    addr.sun_family = AF_UNIX;
    strncpy(addr.sun_path, SV_SOCK_PATH, sizeof(addr.sun_path) - 1);
    if (bind(sfd, (struct sockaddr *)&addr, sizeof(struct sockaddr_un)) == -1) {
        perror("bind"); return 1;
    }
    // ============================================================
    // 4. 开始监听
    // ============================================================
    if (listen(sfd, BACKLOG) == -1) { perror("listen"); return 1; }
    fprintf(stderr, "服务器启动,监听 %s\n", SV_SOCK_PATH);
    // ============================================================
    // 5. 循环处理客户端(迭代式服务器:一次处理一个)
    // ============================================================
    for (;;) {
        // accept() 返回新的已连接套接字 cfd
        // 原监听套接字 sfd 继续接受新连接
        int cfd = accept(sfd, NULL, NULL);
        if (cfd == -1) { perror("accept"); continue; }
        // 从连接套接字读取数据,写到标准输出
        char buf[BUF_SIZE];
        ssize_t numRead;
        while ((numRead = read(cfd, buf, BUF_SIZE)) > 0) {
            if (write(STDOUT_FILENO, buf, numRead) != numRead) {
                fprintf(stderr, "写标准输出失败\n");
                break;
            }
        }
        close(cfd);  // 关闭与该客户端的连接
    }
    // 服务器退出时清理套接字文件(实际上这里不会执行到)
    unlink(SV_SOCK_PATH);
    return 0;
}
客户端
// 文件:us_xfr_cl.cpp
// 功能:UNIX域流套接字客户端,把标准输入发给服务器
// 编译:g++ us_xfr_cl.cpp -o us_xfr_cl
// 用法:./us_xfr_cl < 某个文件
#include <sys/un.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#define SV_SOCK_PATH "/tmp/us_xfr"
#define BUF_SIZE 100
int main()
{
    // 1. 创建客户端套接字(不需要 bind,内核自动分配临时地址)
    int sfd = socket(AF_UNIX, SOCK_STREAM, 0);
    if (sfd == -1) { perror("socket"); return 1; }
    // 2. 连接到服务器套接字路径
    struct sockaddr_un addr;
    memset(&addr, 0, sizeof(struct sockaddr_un));
    addr.sun_family = AF_UNIX;
    strncpy(addr.sun_path, SV_SOCK_PATH, sizeof(addr.sun_path) - 1);
    if (connect(sfd, (struct sockaddr *)&addr, sizeof(struct sockaddr_un)) == -1) {
        perror("connect"); return 1;
    }
    // 3. 把标准输入的内容发给服务器
    char buf[BUF_SIZE];
    ssize_t numRead;
    while ((numRead = read(STDIN_FILENO, buf, BUF_SIZE)) > 0) {
        if (write(sfd, buf, numRead) != numRead) {
            fprintf(stderr, "write 失败\n"); return 1;
        }
    }
    // 客户端退出时 sfd 自动关闭,服务器读到 EOF
    return 0;
}

运行演示:

# 终端1:启动服务器,输出重定向到文件 b
./us_xfr_sv > b &
# 查看套接字文件(显示类型 s)
ls -lF /tmp/us_xfr
# srwxr-xr-x 1 user users 0 ... /tmp/us_xfr=
# 终端2:客户端发送文件 a 的内容
cat *.c > a
./us_xfr_cl < a
# 验证:输入和输出完全一致
diff a b  # 无输出表示相同

1.4 UNIX域数据报套接字示例

UNIX域数据报套接字与网络UDP不同:在内核内完成传输,是可靠的、有序的、不重复的
这个例子实现一个"大写转换服务":客户端发字符串,服务器返回全大写版本。

服务器端
// 文件:ud_ucase_sv.cpp
// 功能:UNIX域数据报服务器,把收到的字符串转为大写后返回
// 编译:g++ ud_ucase_sv.cpp -o ud_ucase_sv
#include <sys/un.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cctype>      // toupper()
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cerrno>
#define BUF_SIZE 10
#define SV_SOCK_PATH "/tmp/ud_ucase"
int main()
{
    // 创建数据报套接字
    int sfd = socket(AF_UNIX, SOCK_DGRAM, 0);
    if (sfd == -1) { perror("socket"); return 1; }
    // 绑定到已知路径(服务器必须绑定,客户端才能找到它)
    if (remove(SV_SOCK_PATH) == -1 && errno != ENOENT) {
        perror("remove"); return 1;
    }
    struct sockaddr_un svaddr;
    memset(&svaddr, 0, sizeof(struct sockaddr_un));
    svaddr.sun_family = AF_UNIX;
    strncpy(svaddr.sun_path, SV_SOCK_PATH, sizeof(svaddr.sun_path) - 1);
    if (bind(sfd, (struct sockaddr *)&svaddr, sizeof(struct sockaddr_un)) == -1) {
        perror("bind"); return 1;
    }
    char buf[BUF_SIZE];
    for (;;) {
        struct sockaddr_un claddr;
        socklen_t len = sizeof(struct sockaddr_un);
        // recvfrom() 接收数据报,同时获得发送方地址(存入 claddr)
        // 数据报套接字保留消息边界,每次 recvfrom 恰好收一条消息
        ssize_t numBytes = recvfrom(sfd, buf, BUF_SIZE, 0,
                                    (struct sockaddr *)&claddr, &len);
        if (numBytes == -1) { perror("recvfrom"); continue; }
        fprintf(stderr, "收到 %ld 字节,来自 %s\n",
                (long)numBytes, claddr.sun_path);
        // 转大写
        for (int j = 0; j < numBytes; j++)
            buf[j] = (char)toupper((unsigned char)buf[j]);
        // 发回给客户端(用 recvfrom 拿到的客户端地址)
        if (sendto(sfd, buf, numBytes, 0,
                   (struct sockaddr *)&claddr, len) != numBytes) {
            fprintf(stderr, "sendto 失败\n");
        }
    }
    return 0;
}
客户端
// 文件:ud_ucase_cl.cpp
// 功能:UNIX域数据报客户端,发送命令行参数,打印大写结果
// 编译:g++ ud_ucase_cl.cpp -o ud_ucase_cl
// 用法:./ud_ucase_cl hello world
#include <sys/un.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#define BUF_SIZE 10
#define SV_SOCK_PATH "/tmp/ud_ucase"
int main(int argc, char *argv[])
{
    if (argc < 2) {
        fprintf(stderr, "用法: %s 消息...\n", argv[0]);
        return 1;
    }
    int sfd = socket(AF_UNIX, SOCK_DGRAM, 0);
    if (sfd == -1) { perror("socket"); return 1; }
    // 客户端也必须绑定,否则服务器不知道往哪里回复
    // 用进程ID构造唯一路径名,避免冲突
    struct sockaddr_un claddr;
    memset(&claddr, 0, sizeof(struct sockaddr_un));
    claddr.sun_family = AF_UNIX;
    snprintf(claddr.sun_path, sizeof(claddr.sun_path),
             "/tmp/ud_ucase_cl.%ld", (long)getpid());
    if (bind(sfd, (struct sockaddr *)&claddr, sizeof(struct sockaddr_un)) == -1) {
        perror("bind"); return 1;
    }
    // 构造服务器地址
    struct sockaddr_un svaddr;
    memset(&svaddr, 0, sizeof(struct sockaddr_un));
    svaddr.sun_family = AF_UNIX;
    strncpy(svaddr.sun_path, SV_SOCK_PATH, sizeof(svaddr.sun_path) - 1);
    char resp[BUF_SIZE];
    for (int j = 1; j < argc; j++) {
        size_t msgLen = strlen(argv[j]);
        // 发送每个命令行参数作为独立数据报
        if (sendto(sfd, argv[j], msgLen, 0,
                   (struct sockaddr *)&svaddr,
                   sizeof(struct sockaddr_un)) != (ssize_t)msgLen) {
            fprintf(stderr, "sendto 失败\n"); continue;
        }
        // 接收服务器响应
        // 注意:如果消息超过 BUF_SIZE,多余部分被静默截断
        ssize_t numBytes = recvfrom(sfd, resp, BUF_SIZE, 0, NULL, NULL);
        if (numBytes == -1) { perror("recvfrom"); continue; }
        printf("响应 %d: %.*s\n", j, (int)numBytes, resp);
    }
    // 删除客户端套接字文件
    remove(claddr.sun_path);
    return 0;
}

截断问题演示:

./ud_ucase_cl hello world
# 正常:响应 1: HELLO  /  响应 2: WORLD
./ud_ucase_cl "long message"
# 截断:"long message" 共12字节,BUF_SIZE=10,只收到 "LONG MESSA"

1.5 套接字文件权限

控制谁能访问 UNIX 域套接字:

  • 连接流套接字 → 需要套接字文件的写权限
  • 发送数据报 → 需要套接字文件的写权限
  • 沿路径名各目录 → 需要执行(搜索)权限
    默认 bind() 创建的套接字对所有人都有权限。如果要限制,在 bind() 之前调用 umask()

1.6 socketpair():创建已连接的套接字对

socketpair() 一步创建两个相互连接的套接字,无需绑定地址,通常配合 fork() 使用:

#include <sys/socket.h>
int socketpair(int domain, int type, int protocol, int sockfd[2]);
// 成功返回 0,失败返回 -1
  • domain 必须是 AF_UNIX(只支持本机)
  • type 可以是 SOCK_STREAMSOCK_DGRAM
  • 返回 sockfd[0]sockfd[1],两个套接字已经连好,可以双向通信
    SOCK_STREAM 时等价于双向管道,比普通管道更灵活。
socketpair() 使用模式:
  父进程调用 socketpair()
       |
     fork()
    /       \
  父进程      子进程
  用 fd[0]   用 fd[1]
  通信        通信

优势:这对套接字不绑定任何地址,其他进程看不到它们,更安全。

1.7 Linux 抽象套接字命名空间

Linux 特有功能:让 UNIX 域套接字绑定到一个不在文件系统中的名字。
创建方法:把 sun_path[0] 设为 \0(空字节),剩余字节为抽象名称:

// 创建抽象套接字绑定示例
struct sockaddr_un addr;
memset(&addr, 0, sizeof(struct sockaddr_un));  // sun_path[0] = '\0'
addr.sun_family = AF_UNIX;
strncpy(&addr.sun_path[1], "xyz", sizeof(addr.sun_path) - 2);
// 抽象名称是 "xyz"(后跟若干空字节),不是文件路径

抽象命名空间的优点:

  • 不担心和文件系统中的名字冲突
  • 套接字关闭后自动删除(不需要 unlink()
  • 在 chroot 环境或无写权限的情况下可用
    注意陷阱: 如果路径字符串恰好是空字符串 ""strncpy 会把 sun_path[0] 设为 \0,无意中创建了一个抽象套接字绑定——这通常是 bug。

2. TCP/IP 网络基础

2.1 互联网络(Internet)的基本概念

互联网络(internet,小写 i):把多个独立网络连接起来,让所有主机都能互相通信。

  • 子网(subnet):组成互联网的每个独立网络
  • 路由器(router):连接子网的专用设备,理解不同子网的协议,转发数据包
  • 多宿主机(multihomed host):有多个网络接口的主机(路由器是一种特殊的多宿主机)
    Internet(大写 I):全球数百万台计算机组成的 TCP/IP 互联网络。

2.2 协议分层

TCP/IP 是分层协议族,每层在下层基础上提供更高级的服务:

用户空间
┌─────────────────────────────────────────┐
│           应用层 (HTTP, FTP, SSH...)     │
├─────────────────────────────────────────┤
│  SOCK_STREAM │ SOCK_DGRAM │ SOCK_RAW    │
├──────────────┼────────────┤             │
│     TCP      │    UDP     │   (直接IP)  │  传输层
├──────────────┴────────────┴─────────────┤
│                 IP                       │  网络层
├─────────────────────────────────────────┤
│           网络接口硬件                   │  数据链路层
└─────────────────────────────────────────┘
内核空间 / 硬件

封装(Encapsulation)原则:每一层把上层数据当作不透明数据,加上自己的头部后向下传递。
封装过程(从应用到网络):

应用数据
  ↓ 加 TCP 头部(源/目的端口、序列号、校验和等)
TCP 段(segment)
  ↓ 加 IP 头部(源/目的IP地址、校验和等)
IP 数据报(datagram)
  ↓ 加以太网帧头部
以太网帧(frame)→ 在物理网络上传输

2.3 IP 层

IP 是无连接的、不可靠的:

  • 无连接:每个数据报独立路由,可能走不同路径
  • 不可靠:不保证顺序、不保证不重复、不保证到达
    IP 分片(Fragmentation):
  • 每个数据链路层有 MTU(最大传输单元),以太网 MTU = 1500 字节
  • IP 数据报最大可达 65535 字节(IPv4)
  • 超过 MTU 时,IP 自动分片,在目的地重组
  • 分片是透明的(高层不感知),但效率差——任何一片丢失都导致整个数据报无效
    路径 MTU(Path MTU):从源到目的地所有链路 MTU 的最小值。TCP 有路径 MTU 发现机制,UDP 没有。

2.4 IP 地址

IPv4 地址

32 位,点分十进制表示,如 204.152.189.116
地址由两部分组成:
IPv4地址 = 网络ID(Network ID) + 主机ID(Host ID) \text{IPv4地址} = \text{网络ID(Network ID)} + \text{主机ID(Host ID)} IPv4地址=网络IDNetwork ID+主机IDHost ID
网络掩码表示哪些位是网络ID:
例如: 204.152.189.0 / 24 ⇒ 前24位是网络ID,后8位是主机ID \text{例如:} 204.152.189.0/24 \Rightarrow \text{前24位是网络ID,后8位是主机ID} 例如:204.152.189.0/2424位是网络ID,后8位是主机ID
可用主机地址数(全0和全1不可用):
可用地址数 = 2 32 − 前缀长度 − 2 \text{可用地址数} = 2^{32 - \text{前缀长度}} - 2 可用地址数=232前缀长度2
对于 /24 2 8 − 2 = 254 2^8 - 2 = 254 282=254 个可用地址。
特殊地址:

地址 含义 C常量
127.0.0.1 回环地址(loopback),不走网络 INADDR_LOOPBACK
0.0.0.0 通配地址,监听所有接口 INADDR_ANY
x.x.x.255 子网广播地址 -

IPv6 地址

128 位,冒号分隔的十六进制,如 F000:0:0:0:0:0:A:1
连续的0可以用 :: 缩写(只能用一次):F000::A:1
特殊地址:

  • 回环:::1(127个0后跟一个1)
  • 通配:::(全0)
  • IPv4映射地址:::FFFF:204.152.189.116
    IPv6 解决了 IPv4 地址耗尽问题: 2 128 2^{128} 2128 个地址空间。

2.5 传输层:端口号

端口号是 16 位整数(0~65535),用于区分同一主机上的不同应用。

范围 类型 说明
0 ~ 1023 众所周知端口(Well-known) 由 IANA 分配,需要 root 权限绑定
1024 ~ 41951 注册端口(Registered) IANA 登记但不强制
49152 ~ 65535 动态/私有端口 用作临时端口(ephemeral)

常见服务端口:

服务 端口
SSH 22
HTTP 80
HTTPS 443
DNS 53
SMTP 25

临时端口(Ephemeral Port):客户端不 bind() 时,内核自动分配一个临时端口。Linux 的范围由 /proc/sys/net/ipv4/ip_local_port_range 控制。

2.6 UDP

UDP 在 IP 基础上只增加了两件事:

  1. 端口号(区分应用)
  2. 数据校验和(检测错误)
    UDP 依然是无连接、不可靠的。适合:DNS查询、视频流、游戏等对延迟敏感但能容忍丢包的场景。
    UDP 避免IP分片的建议:
    IPv4 最小重组缓冲区 = 576 字节,减去 IP 头(最小20字节)和 UDP 头(8字节):
    安全UDP数据大小 = 576 − 20 − 8 = 548  字节 \text{安全UDP数据大小} = 576 - 20 - 8 = 548 \text{ 字节} 安全UDP数据大小=576208=548 字节
    实践中很多应用取更保守的 512 字节

2.7 TCP

TCP 提供可靠、面向连接、双向字节流通信。它通过以下机制实现可靠性:

连接建立(三次握手)
客户端          服务器
  |──── SYN ────>|   第1次:客户端发 SYN,携带初始序列号 ISN_c
  |<─── SYN+ACK ─|   第2次:服务器发 SYN+ACK,确认客户端序列号,携带自己的 ISN_s
  |──── ACK ────>|   第3次:客户端确认服务器序列号
  |  连接建立!  |

初始序列号 ISN(Initial Sequence Number) 不从0开始,而是通过算法生成,防止旧连接的数据包被误认为新连接的。

可靠性机制
  • 序列号(Sequence Number):每字节都有编号,接收方可以排序、去重
  • 确认(ACK):收到数据后发 ACK,告知发送方
  • 超时重传:发送方设定计时器,超时未收到 ACK 则重传
  • 滑动窗口(Sliding Window):允许发送方在未收到 ACK 的情况下,最多发送 N N N(窗口大小)字节
流量控制(Flow Control)

防止快速发送方压垮慢速接收方:
接收方广播窗口大小 W r ⇒ 发送方最多有  W r  字节在途 \text{接收方广播窗口大小} W_r \Rightarrow \text{发送方最多有 } W_r \text{ 字节在途} 接收方广播窗口大小Wr发送方最多有 Wr 字节在途
窗口为0时,发送方停止发送。

拥塞控制(Congestion Control)

防止压垮网络,分两个算法:

  1. 慢启动(Slow Start):连接初期,拥塞窗口 W c W_c Wc 指数增长
  2. 拥塞避免(Congestion Avoidance):超过阈值后线性增长
    实际发送量 = min ⁡ ( W r , W c ) \text{实际发送量} = \min(W_r, W_c) 实际发送量=min(Wr,Wc)

3. Internet域套接字

3.1 字节序转换

不同硬件架构存储多字节整数的顺序不同:

  • 大端(Big-Endian):高位字节在低地址(网络字节序就是大端)
  • 小端(Little-Endian):低位字节在低地址(x86 是小端)
    IP地址和端口号在网络传输时必须用网络字节序(大端)
    转换函数:
#include <arpa/inet.h>
uint16_t htons(uint16_t host_uint16);  // host to network short(端口号)
uint32_t htonl(uint32_t host_uint32);  // host to network long(IPv4地址)
uint16_t ntohs(uint16_t net_uint16);   // network to host short
uint32_t ntohl(uint32_t net_uint32);   // network to host long

函数名解读:h = host,n = network,s = short(16位),l = long(32位)。

3.2 Internet 套接字地址结构

IPv4 地址结构
struct sockaddr_in {
    sa_family_t    sin_family;  // AF_INET
    in_port_t      sin_port;    // 16位端口号(网络字节序)
    struct in_addr sin_addr;    // 32位IPv4地址(网络字节序)
    unsigned char  __pad[X];    // 填充到与 sockaddr 一样大
};
struct in_addr {
    in_addr_t s_addr;           // 32位无符号整数
};
IPv6 地址结构
struct sockaddr_in6 {
    sa_family_t     sin6_family;    // AF_INET6
    in_port_t       sin6_port;      // 16位端口号(网络字节序)
    uint32_t        sin6_flowinfo;  // 流信息(通常设0)
    struct in6_addr sin6_addr;      // 128位IPv6地址
    uint32_t        sin6_scope_id;  // 作用域ID(通常设0)
};
struct in6_addr {
    uint8_t s6_addr[16];  // 16字节 = 128位
};

IPv6通配地址初始化:

struct sockaddr_in6 addr;
memset(&addr, 0, sizeof(struct sockaddr_in6));
addr.sin6_family = AF_INET6;
addr.sin6_addr = in6addr_any;       // 全局变量,等价于 ::
addr.sin6_port = htons(8080);

3.3 IP地址与字符串互转

inet_pton() / inet_ntop()(现代,支持IPv4和IPv6)
#include <arpa/inet.h>
// 字符串 → 二进制(p = presentation表示形式,n = network二进制)
int inet_pton(int domain, const char *src_str, void *addrptr);
// 成功返回1,字符串格式不对返回0,错误返回-1
// 二进制 → 字符串
const char *inet_ntop(int domain, const void *addrptr,
                      char *dst_str, size_t len);
// 成功返回 dst_str,失败返回 NULL

缓冲区大小常量:

  • INET_ADDRSTRLEN = 16(IPv4点分十进制最大长度)
  • INET6_ADDRSTRLEN = 46(IPv6十六进制字符串最大长度)

3.4 getaddrinfo():主机名和服务名解析

这是现代的"将主机名+端口名转换为套接字地址"的标准函数,同时支持 IPv4 和 IPv6:

#include <sys/socket.h>
#include <netdb.h>
int getaddrinfo(const char *host,           // 主机名或IP字符串(NULL表示本机)
                const char *service,        // 服务名或端口号字符串
                const struct addrinfo *hints, // 过滤条件
                struct addrinfo **result);  // 返回结果链表
// 成功返回0,失败返回非零错误码

返回的链表结构:

result → addrinfo[0] → addrinfo[1] → ... → NULL
            |               |
            ↓               ↓
        sockaddr_in     sockaddr_in6

addrinfo 结构字段:

struct addrinfo {
    int              ai_flags;      // 标志(AI_* 常量)
    int              ai_family;     // AF_INET 或 AF_INET6
    int              ai_socktype;   // SOCK_STREAM 或 SOCK_DGRAM
    int              ai_protocol;   // 协议(通常0)
    size_t           ai_addrlen;    // ai_addr 指向结构的大小
    char            *ai_canonname;  // 主机规范名(仅第一个节点,需 AI_CANONNAME)
    struct sockaddr *ai_addr;       // 套接字地址结构
    struct addrinfo *ai_next;       // 链表下一个节点
};

hints 常用设置:

hints 字段 常用值 含义
ai_family AF_UNSPEC IPv4和IPv6都返回
ai_socktype SOCK_STREAM 只要TCP地址
ai_flags AI_PASSIVE 返回适合 bind() 的通配地址
ai_flags AI_NUMERICSERV service 是数字字符串,不做名字查找

使用完必须调用 freeaddrinfo(result) 释放内存。

getnameinfo():反向解析

把套接字地址结构转回主机名和服务名:

int getnameinfo(const struct sockaddr *addr, socklen_t addrlen,
                char *host, size_t hostlen,
                char *service, size_t servlen,
                int flags);
// 成功返回0

推荐缓冲区大小:NI_MAXHOST(1025字节)和 NI_MAXSERV(32字节)。

3.5 完整示例:TCP 序列号分配服务器和客户端

这是一个经典的"迭代式 TCP 服务器"示例。服务器分配连续的序列号,客户端请求一段,服务器返回起始号。
所有整数以文本形式 + 换行符传输,避免字节序问题。

完整服务器代码
// 文件:is_seqnum_sv.cpp
// 功能:TCP序列号服务器(迭代式),监听连接并分配序列号
// 编译:g++ is_seqnum_sv.cpp -o is_seqnum_sv
#define _BSD_SOURCE   // 获取 NI_MAXHOST, NI_MAXSERV
#include <netdb.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <signal.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cerrno>
#define PORT_NUM "50000"   // 服务器端口(字符串,传给 getaddrinfo)
#define INT_LEN  30        // 存放整数字符串的缓冲区大小
#define BACKLOG  50        // 待处理连接队列长度
// 按行读取函数:从 fd 读数据直到遇到 '\n' 或读满 n-1 字节
static ssize_t readLine(int fd, void *buffer, size_t n)
{
    char *buf = (char *)buffer;
    size_t totRead = 0;
    char ch;
    if (n <= 0 || buffer == NULL) { errno = EINVAL; return -1; }
    for (;;) {
        ssize_t numRead = read(fd, &ch, 1);
        if (numRead == -1) {
            if (errno == EINTR) continue;  // 被信号打断,重试
            return -1;
        } else if (numRead == 0) {         // EOF
            if (totRead == 0) return 0;
            break;
        } else {
            if (totRead < n - 1) { buf[totRead++] = ch; }
            if (ch == '\n') break;
        }
    }
    buf[totRead] = '\0';
    return (ssize_t)totRead;
}
int main(int argc, char *argv[])
{
    // 序列号初始值:命令行指定或默认0
    uint32_t seqNum = (argc > 1) ? (uint32_t)atoi(argv[1]) : 0;
    // 忽略 SIGPIPE:对端关闭时 write() 返回 EPIPE 而非收到 SIGPIPE 信号
    signal(SIGPIPE, SIG_IGN);
    // ============================================================
    // 调用 getaddrinfo() 获取可用地址列表
    // AI_PASSIVE:绑定通配地址(0.0.0.0 或 ::)
    // AF_UNSPEC:同时支持 IPv4 和 IPv6
    // ============================================================
    struct addrinfo hints;
    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_family   = AF_UNSPEC;
    hints.ai_flags    = AI_PASSIVE | AI_NUMERICSERV;
    struct addrinfo *result;
    if (getaddrinfo(NULL, PORT_NUM, &hints, &result) != 0) {
        fprintf(stderr, "getaddrinfo 失败\n"); return 1;
    }
    // 遍历地址列表,找到第一个能成功创建并绑定套接字的地址
    int lfd = -1;
    int optval = 1;
    for (struct addrinfo *rp = result; rp != NULL; rp = rp->ai_next) {
        lfd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
        if (lfd == -1) continue;
        // SO_REUSEADDR:允许重用处于 TIME_WAIT 状态的端口,服务器重启更快
        setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
        if (bind(lfd, rp->ai_addr, rp->ai_addrlen) == 0) break;  // 绑定成功
        close(lfd);
        lfd = -1;
    }
    freeaddrinfo(result);
    if (lfd == -1) { fprintf(stderr, "无法绑定套接字\n"); return 1; }
    if (listen(lfd, BACKLOG) == -1) { perror("listen"); return 1; }
    fprintf(stderr, "序列号服务器启动,端口 %s\n", PORT_NUM);
    // 迭代式主循环:一次处理一个客户端
    for (;;) {
        struct sockaddr_storage claddr;
        socklen_t addrlen = sizeof(struct sockaddr_storage);
        // accept() 阻塞等待客户端连接
        int cfd = accept(lfd, (struct sockaddr *)&claddr, &addrlen);
        if (cfd == -1) { perror("accept"); continue; }
        // 打印客户端地址信息(用 getnameinfo 解析)
        char host[NI_MAXHOST], service[NI_MAXSERV];
        if (getnameinfo((struct sockaddr *)&claddr, addrlen,
                        host, NI_MAXHOST, service, NI_MAXSERV, 0) == 0) {
            printf("客户端连接来自: (%s, %s)\n", host, service);
        }
        // 读取客户端请求的序列号长度(以换行结尾的字符串)
        char reqLenStr[INT_LEN];
        if (readLine(cfd, reqLenStr, INT_LEN) <= 0) { close(cfd); continue; }
        int reqLen = atoi(reqLenStr);
        if (reqLen <= 0) { close(cfd); continue; }
        // 把当前序列号发回客户端(以换行结尾)
        char seqNumStr[INT_LEN];
        snprintf(seqNumStr, INT_LEN, "%u\n", seqNum);
        if (write(cfd, seqNumStr, strlen(seqNumStr)) != (ssize_t)strlen(seqNumStr)) {
            fprintf(stderr, "write 失败\n");
        }
        // 更新序列号
        seqNum += (uint32_t)reqLen;
        close(cfd);
    }
    return 0;
}
完整客户端代码
// 文件:is_seqnum_cl.cpp
// 功能:TCP序列号客户端,向服务器请求序列号
// 编译:g++ is_seqnum_cl.cpp -o is_seqnum_cl
// 用法:./is_seqnum_cl localhost [请求数量]
#include <netdb.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cerrno>
#define PORT_NUM "50000"
#define INT_LEN 30
static ssize_t readLine(int fd, void *buffer, size_t n)
{
    char *buf = (char *)buffer;
    size_t totRead = 0;
    char ch;
    if (n <= 0 || buffer == NULL) { errno = EINVAL; return -1; }
    for (;;) {
        ssize_t numRead = read(fd, &ch, 1);
        if (numRead == -1) {
            if (errno == EINTR) continue;
            return -1;
        } else if (numRead == 0) {
            if (totRead == 0) return 0;
            break;
        } else {
            if (totRead < n - 1) { buf[totRead++] = ch; }
            if (ch == '\n') break;
        }
    }
    buf[totRead] = '\0';
    return (ssize_t)totRead;
}
int main(int argc, char *argv[])
{
    if (argc < 2) {
        fprintf(stderr, "用法: %s 服务器主机名 [请求序列号数量]\n", argv[0]);
        return 1;
    }
    // 获取服务器地址列表
    struct addrinfo hints;
    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_family   = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_flags    = AI_NUMERICSERV;
    struct addrinfo *result;
    if (getaddrinfo(argv[1], PORT_NUM, &hints, &result) != 0) {
        fprintf(stderr, "getaddrinfo 失败\n"); return 1;
    }
    // 遍历地址列表,尝试连接
    int cfd = -1;
    for (struct addrinfo *rp = result; rp != NULL; rp = rp->ai_next) {
        cfd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
        if (cfd == -1) continue;
        if (connect(cfd, rp->ai_addr, rp->ai_addrlen) != -1) break;  // 连接成功
        close(cfd);
        cfd = -1;
    }
    freeaddrinfo(result);
    if (cfd == -1) { fprintf(stderr, "无法连接到服务器\n"); return 1; }
    // 发送请求的序列号数量(以换行结尾)
    const char *reqLen = (argc > 2) ? argv[2] : "1";
    if (write(cfd, reqLen, strlen(reqLen)) != (ssize_t)strlen(reqLen)) {
        fprintf(stderr, "write 失败\n"); return 1;
    }
    if (write(cfd, "\n", 1) != 1) { fprintf(stderr, "write 失败\n"); return 1; }
    // 读取并打印服务器返回的序列号
    char seqNumStr[INT_LEN];
    ssize_t numRead = readLine(cfd, seqNumStr, INT_LEN);
    if (numRead <= 0) { fprintf(stderr, "读取响应失败\n"); return 1; }
    printf("序列号: %s", seqNumStr);  // seqNumStr 包含 '\n'
    close(cfd);
    return 0;
}

运行效果:

./is_seqnum_sv &         # 启动服务器
./is_seqnum_cl localhost     # 客户端1:请求1个
# 序列号: 0
./is_seqnum_cl localhost 10  # 客户端2:请求10个
# 序列号: 1
./is_seqnum_cl localhost     # 客户端3:请求1个
# 序列号: 11(因为前面已经分配了0~10)

3.6 DNS 域名系统

DNS 是一个分布式数据库,把主机名映射到 IP 地址(及反向映射)。
DNS 层次结构:

匿名根节点(.)

com

org

net

nz

google

example

www
(www.google.com.)

kernel

www
(www.kernel.org.)

ac

canterbury

www
(www.canterbury.ac.nz.)

域名解析过程(迭代解析)
以查询 www.kernel.org 为例:

本地DNS服务器
  → 查询根服务器:www.kernel.org 在哪?
  ← 根服务器说:去问 org 的 DNS 服务器
  → 查询 org DNS:www.kernel.org 在哪?
  ← org DNS 说:去问 kernel.org 的 DNS 服务器
  → 查询 kernel.org DNS:www.kernel.org 的 IP?
  ← kernel.org DNS 返回:203.0.113.42

3.7 /etc/services 文件

存储服务名与端口号的映射:

# 服务名  端口/协议  [别名]
ssh        22/tcp
http       80/tcp
https      443/tcp
dns        53/tcp
dns        53/udp   domain

getaddrinfo()getnameinfo() 都会查询这个文件。

4. 服务器设计

4.1 迭代式 vs 并发式服务器


类型 特点 适用场景
迭代式(Iterative) 一次处理一个客户端,处理完再接下一个 请求处理时间短,如 UDP 服务
并发式(Concurrent) 同时处理多个客户端 处理时间长,如 TCP 长连接

4.2 迭代式 UDP echo 服务器

UDP echo 服务(端口7):收到什么就原样返回。
UDP 天然适合迭代式,因为每次 recvfrom() 处理一个独立的数据报,很快就完成。

// 文件:udp_echo_sv.cpp
// 功能:UDP echo 服务器(迭代式),原样返回收到的数据报
// 编译:g++ udp_echo_sv.cpp -o udp_echo_sv
// 注意:绑定端口 7 需要 root 权限
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cerrno>
#define BUF_SIZE 500
int main()
{
    // 使用 getaddrinfo 创建绑定到 echo 端口的 UDP 套接字
    struct addrinfo hints;
    memset(&hints, 0, sizeof(hints));
    hints.ai_family   = AF_UNSPEC;     // IPv4 或 IPv6
    hints.ai_socktype = SOCK_DGRAM;    // UDP
    hints.ai_flags    = AI_PASSIVE | AI_NUMERICSERV;
    struct addrinfo *result;
    // 使用服务名 "echo" 或端口号字符串 "7"
    if (getaddrinfo(NULL, "7", &hints, &result) != 0) {
        fprintf(stderr, "getaddrinfo 失败\n"); return 1;
    }
    int sfd = -1;
    for (struct addrinfo *rp = result; rp != NULL; rp = rp->ai_next) {
        sfd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
        if (sfd == -1) continue;
        if (bind(sfd, rp->ai_addr, rp->ai_addrlen) == 0) break;
        close(sfd); sfd = -1;
    }
    freeaddrinfo(result);
    if (sfd == -1) { fprintf(stderr, "无法绑定套接字\n"); return 1; }
    fprintf(stderr, "UDP echo 服务器启动,端口 7\n");
    char buf[BUF_SIZE];
    for (;;) {
        struct sockaddr_storage claddr;
        socklen_t len = sizeof(struct sockaddr_storage);
        // 接收数据报,同时获得发送方地址
        ssize_t numRead = recvfrom(sfd, buf, BUF_SIZE, 0,
                                    (struct sockaddr *)&claddr, &len);
        if (numRead == -1) { perror("recvfrom"); continue; }
        // 原样发回给发送方
        if (sendto(sfd, buf, numRead, 0,
                   (struct sockaddr *)&claddr, len) != numRead) {
            perror("sendto");
        }
    }
    return 0;
}

4.3 并发式 TCP echo 服务器

TCP 连接可能持续很长时间(客户端可以不断发数据),所以需要并发处理多个连接。
传统方案:每个客户端 fork 一个子进程。

主进程(父进程)
  监听套接字 lfd
       |
  accept() → 新连接 cfd
       |
     fork()
    /       \
  父进程     子进程
  关闭 cfd   关闭 lfd
  回到循环   处理客户端
  接受下一个  handleRequest(cfd)
  连接        _exit()

关键细节:

  • fork() 后,lfdcfd 在父子进程中都有副本
  • 父进程必须关闭 cfd(否则连接永远不会关闭)
  • 子进程必须关闭 lfd(否则浪费文件描述符,且阻止监听套接字真正关闭)
  • SIGCHLD 信号处理器回收僵尸子进程
// 文件:tcp_echo_sv.cpp
// 功能:并发式 TCP echo 服务器,每个客户端 fork 一个子进程处理
// 编译:g++ tcp_echo_sv.cpp -o tcp_echo_sv
#include <sys/socket.h>
#include <sys/wait.h>    // waitpid()
#include <netdb.h>
#include <signal.h>
#include <unistd.h>
#include <syslog.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cerrno>
#define BUF_SIZE 4096
// ============================================================
// SIGCHLD 信号处理器:循环回收所有已结束的子进程(僵尸进程)
// 使用 WNOHANG 避免阻塞,防止遗漏多个子进程同时结束的情况
// ============================================================
static void grimReaper(int sig)
{
    int savedErrno = errno;   // 保存 errno,防止被 waitpid 修改
    while (waitpid(-1, NULL, WNOHANG) > 0)
        continue;
    errno = savedErrno;
}
// ============================================================
// 子进程处理函数:把 cfd 收到的所有数据原样写回
// ============================================================
static void handleRequest(int cfd)
{
    char buf[BUF_SIZE];
    ssize_t numRead;
    while ((numRead = read(cfd, buf, BUF_SIZE)) > 0) {
        if (write(cfd, buf, numRead) != numRead) {
            syslog(LOG_ERR, "write 失败: %s", strerror(errno));
            return;
        }
    }
    if (numRead == -1) {
        syslog(LOG_ERR, "read 错误: %s", strerror(errno));
    }
}
int main()
{
    // 注册 SIGCHLD 处理器(SA_RESTART:被信号打断的系统调用自动重启)
    struct sigaction sa;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags   = SA_RESTART;
    sa.sa_handler = grimReaper;
    if (sigaction(SIGCHLD, &sa, NULL) == -1) {
        perror("sigaction"); return 1;
    }
    // 创建并绑定监听套接字
    struct addrinfo hints;
    memset(&hints, 0, sizeof(hints));
    hints.ai_family   = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_flags    = AI_PASSIVE | AI_NUMERICSERV;
    struct addrinfo *result;
    if (getaddrinfo(NULL, "7", &hints, &result) != 0) {
        fprintf(stderr, "getaddrinfo 失败\n"); return 1;
    }
    int lfd = -1;
    int optval = 1;
    for (struct addrinfo *rp = result; rp != NULL; rp = rp->ai_next) {
        lfd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
        if (lfd == -1) continue;
        setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
        if (bind(lfd, rp->ai_addr, rp->ai_addrlen) == 0) break;
        close(lfd); lfd = -1;
    }
    freeaddrinfo(result);
    if (lfd == -1 || listen(lfd, 10) == -1) {
        fprintf(stderr, "套接字初始化失败\n"); return 1;
    }
    fprintf(stderr, "TCP echo 服务器启动,端口 7\n");
    for (;;) {
        // 等待新连接
        int cfd = accept(lfd, NULL, NULL);
        if (cfd == -1) {
            if (errno == EINTR) continue;  // 被信号打断,重试
            perror("accept"); continue;
        }
        switch (fork()) {
        case -1:
            // fork 失败,关闭连接,继续等待下一个客户端
            syslog(LOG_ERR, "fork 失败: %s", strerror(errno));
            close(cfd);
            break;
        case 0:
            // ── 子进程 ──
            // 子进程不需要监听套接字,关闭它
            close(lfd);
            handleRequest(cfd);  // 处理客户端请求
            _exit(EXIT_SUCCESS); // 子进程直接退出(不运行 atexit 等)
        default:
            // ── 父进程 ──
            // 父进程不需要已连接套接字,关闭它(不然连接永远不会结束)
            close(cfd);
            break;  // 回到循环,接受下一个连接
        }
    }
    return 0;
}

4.4 其他并发服务器设计

预分叉/预线程(Preforked/Prethreaded)

传统方案每个连接都 fork(),有性能开销。预分叉方案:

启动时预先创建 N 个子进程,组成"服务器池"
            |
      每个子进程调用 accept() 等待连接
            |
      谁先 accept 成功,谁处理该客户端
            |
      处理完不退出,继续等待下一个连接

父进程监控池大小,负载高时增加子进程,负载低时减少。

单进程处理多客户端

用 I/O 多路复用(select()/poll()/epoll()):一个进程同时监控多个套接字,哪个就绪就处理哪个。

服务器集群(Server Farm)
  • DNS 轮询:同一域名映射多个 IP,每次 DNS 查询返回不同顺序
  • 负载均衡服务器:专门路由请求到后端服务器

4.5 inetd:Internet 超级服务器

问题/etc/services 列出了数百个服务,若每个服务都运行一个守护进程,大多数时候它们什么都不做,浪费资源。
解决方案inetd 监控所有端口,需要时才启动对应服务。

inetd 工作流程:
  读取 /etc/inetd.conf 配置
        |
  为每个服务创建套接字并 bind
        |
  select() 监控所有套接字
        |
  某端口有活动(UDP数据报 / TCP连接)
        |
  TCP → 先 accept()
        |
      fork()
        |
  子进程:
    关闭其他所有fd
    把套接字复制到 stdin/stdout/stderr(fd 0/1/2)
    exec() 对应的服务程序
        |
  父进程:
    关闭 cfd
    回到 select()

/etc/inetd.conf 格式(每行一个服务):

# 服务名  套接字类型  协议  标志      用户   服务程序路径  程序参数
echo      stream     tcp   nowait    root   internal
telnet    stream     tcp   nowait    root   /usr/sbin/tcpd  in.telnetd

字段说明:

字段 说明
服务名 对应 /etc/services 中的名称
套接字类型 stream(TCP)或 dgram(UDP)
协议 tcpudp
标志 nowait(TCP常用)或 wait(UDP常用)
用户 运行服务的用户ID
服务程序 完整路径,或 internal(inetd内建)

nowait vs wait:

  • nowait:inetd 继续监听,不等服务程序结束(适合 TCP,每次处理一个连接后退出)
  • wait:inetd 把套接字从监控集合移除,等服务程序结束(适合 UDP,服务程序处理完所有数据报再退出)
    inetd 简化的 TCP echo 服务程序
// 文件:echo_inetd.cpp
// 功能:由 inetd 调用的 TCP echo 服务程序
// inetd 已经做了 socket/bind/listen/accept/fork,并把连接映射到 stdin/stdout
// 所以这个程序只需从 stdin 读,写到 stdout
// 编译:g++ echo_inetd.cpp -o echo_inetd
#include <unistd.h>
#include <syslog.h>
#include <cstdio>
#include <cstring>
#include <cerrno>
#define BUF_SIZE 4096
int main()
{
    char buf[BUF_SIZE];
    ssize_t numRead;
    // inetd 已经把连接套接字挂在 STDIN_FILENO 上
    // 从 stdin 读,写到 stdout,实现 echo
    while ((numRead = read(STDIN_FILENO, buf, BUF_SIZE)) > 0) {
        if (write(STDOUT_FILENO, buf, numRead) != numRead) {
            syslog(LOG_ERR, "write 失败: %s", strerror(errno));
            return 1;
        }
    }
    if (numRead == -1) {
        syslog(LOG_ERR, "read 失败: %s", strerror(errno));
        return 1;
    }
    return 0;
}

对应的 /etc/inetd.conf 行:

echo stream tcp nowait root /path/to/echo_inetd echo_inetd

总结

四章核心脉络

UNIX域套接字
(第57章)
同机IPC,文件路径寻址

TCP/IP基础
(第58章)
协议分层,IP/UDP/TCP

Internet域套接字
(第59章)
网络编程,getaddrinfo

服务器设计
(第60章)
迭代/并发/inetd

关键要点回顾

UNIX域套接字:

  • 地址是文件路径,bind() 在文件系统创建特殊文件
  • 数据报是可靠的(内核内完成)
  • 客户端数据报套接字也必须 bind() 否则服务器不知道往哪里回复
  • 抽象命名空间:sun_path[0]='\0',不创建文件,关闭自动清理
    TCP/IP 基础:
  • IP 无连接、不可靠,靠 TCP 提供可靠性
  • 端口号区分应用,IPv4 = 32位,IPv6 = 128位
  • 字节序:网络字节序=大端,用 htonl/htons/ntohl/ntohs 转换
    Internet域套接字:
  • 使用 getaddrinfo() 代替旧的 gethostbyname(),同时支持 IPv4/IPv6
  • 结果是链表,用完调 freeaddrinfo() 释放
  • 数据建议用文本+换行传输,避免跨平台字节序问题
    服务器设计:
  • UDP 短请求 → 迭代式;TCP 长连接 → 并发式(fork子进程)
  • 并发服务器 fork 后:父进程关 cfd,子进程关 lfd
  • SIGCHLD + waitpid(WNOHANG) 回收僵尸进程
  • inetd 集中管理多服务,节省资源,简化服务程序编写

Linux 系统编程:高级 I/O 与套接字专题笔记

本笔记涵盖:套接字高级主题、终端编程、替代 I/O 模型、伪终端,内容力求通俗易懂。

目录

  1. 套接字高级主题(第61章)
  2. 终端编程(第62章)
  3. 替代 I/O 模型(第63章)
  4. 伪终端(第64章)

1. 套接字高级主题

1.1 流式套接字的部分读写

什么叫"部分读写"?
想象你在往水桶里倒水,理想情况下你一次性把水全倒进去,但有时可能只倒进去一半,需要再倒一次。套接字的读写也存在这种情况。
部分读(Partial Read)发生的原因:

  • 套接字缓冲区里的数据比你请求读取的字节数少
    部分写(Partial Write)发生的原因:
  • 缓冲区空间不足 + 以下任一条件:
    • 信号处理函数中途打断了 write() 调用
    • 套接字处于非阻塞模式(O_NONBLOCK
    • 发生了异步错误(如 TCP 连接对端崩溃)
      解决方案:用循环来保证完整读写
#include <unistd.h>
#include <cerrno>
#include <cstddef>
// 完整读取 n 个字节,返回实际读取数,0 表示 EOF,-1 表示出错
ssize_t readn(int fd, void* buffer, size_t n) {
    ssize_t numRead;           // 本次 read() 读取的字节数
    size_t  totRead = 0;       // 已累计读取的字节数
    char*   buf = static_cast<char*>(buffer);
    while (totRead < n) {
        numRead = read(fd, buf, n - totRead);
        if (numRead == 0)          // 遇到 EOF
            return totRead;        // 返回已读取的字节数(可能为0)
        if (numRead == -1) {
            if (errno == EINTR)    // 被信号打断 → 重新 read()
                continue;
            else
                return -1;         // 真正的错误
        }
        totRead += numRead;        // 累加已读字节数
        buf     += numRead;        // 移动缓冲区指针
    }
    return totRead;                // 成功读完 n 字节
}
// 完整写入 n 个字节,返回实际写入数,-1 表示出错
ssize_t writen(int fd, const void* buffer, size_t n) {
    ssize_t     numWritten;
    size_t      totWritten = 0;
    const char* buf = static_cast<const char*>(buffer);
    while (totWritten < n) {
        numWritten = write(fd, buf, n - totWritten);
        if (numWritten <= 0) {
            if (numWritten == -1 && errno == EINTR)
                continue;          // 被信号打断 → 重新 write()
            else
                return -1;         // 真正的错误
        }
        totWritten += numWritten;
        buf        += numWritten;
    }
    return totWritten;
}

1.2 shutdown() 系统调用

close() 和 shutdown() 的区别
close() 好比直接把电话挂掉,双方都无法继续通话。
shutdown() 更精细,可以只挂掉"说话"的那一半,或者只挂掉"听话"的那一半。

close()    → 双向全断
shutdown() → 可以只断一个方向

函数原型:

#include <sys/socket.h>
int shutdown(int sockfd, int how);
// 成功返回 0,失败返回 -1

how 参数 含义
SHUT_RD 关闭读通道,之后 read() 返回 0(EOF)
SHUT_WR 关闭写通道,对端收到 EOF;本端仍可读
SHUT_RDWR 关闭读写两个通道(等价于 SHUT_RD + SHUT_WR)

重要区别:
shutdown() 会对底层文件描述所引用的"打开文件描述"生效,不管有多少个文件描述符指向同一个套接字
close() 只有在最后一个指向该套接字的文件描述符关闭时才真正关闭连接。
使用示例:

fd2 = dup(sockfd);
close(sockfd);
// 此时连接仍然存在!因为 fd2 还指向同一个套接字
// 可以用 fd2 继续通信
fd2 = dup(sockfd);
shutdown(sockfd, SHUT_RDWR);
// 此时连接真正断开!fd2 也无法再通信了

1.3 recv() 和 send() 系统调用

这两个函数是 read() / write() 的套接字专用增强版,多了一个 flags 参数。

#include <sys/socket.h>
ssize_t recv(int sockfd, void* buffer, size_t length, int flags);
ssize_t send(int sockfd, const void* buffer, size_t length, int flags);

recv() 常用标志:

标志 说明
MSG_DONTWAIT 非阻塞接收,数据不可用时立即返回 EAGAIN(单次调用有效)
MSG_PEEK 窥探数据:复制缓冲区内容但不移除,下次还能读到
MSG_WAITALL 阻塞直到读满 length 字节才返回
MSG_OOB 接收带外数据

send() 常用标志:

标志 说明
MSG_DONTWAIT 非阻塞发送
MSG_NOSIGNAL 对端关闭时不发送 SIGPIPE 信号,改为返回 EPIPE 错误
MSG_MORE 告诉内核还有更多数据要发,先攒起来一起发(类似 TCP_CORK
MSG_OOB 发送带外数据

1.4 sendfile() 系统调用

问题背景:
传统做法把文件发送到套接字需要两步:

  1. read() 把文件从内核缓冲区读到用户空间
  2. write() 把用户空间数据写回内核再发到网络
    这两步都有数据拷贝,效率低。
    sendfile() 的零拷贝(Zero-Copy):
传统方式(两次拷贝):
磁盘 → [内核缓冲] → [用户空间] → [内核套接字缓冲] → 网络
sendfile() 方式(一次拷贝):
磁盘 → [内核缓冲] ────────────→ [内核套接字缓冲] → 网络
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t* offset, size_t count);
// out_fd 必须是套接字
// in_fd  必须是可以 mmap() 的文件(通常是普通文件)
// offset 不为 NULL 时:从 *offset 开始传,传完后更新 *offset,不改变 in_fd 的文件偏移
// offset 为 NULL 时:从当前文件偏移开始传,传完后更新文件偏移

TCP_CORK 选项(与 sendfile 配合使用):
想象一个软木塞(cork)堵住了水管出口。启用 TCP_CORK 后,数据先积攒,达到最大段大小或者拔掉软木塞时才一起发出去。

int optval;
// 启用 TCP_CORK:后续输出先积攒
optval = 1;
setsockopt(sockfd, IPPROTO_TCP, TCP_CORK, &optval, sizeof(optval));
write(sockfd, httpHeaders, headersLen);   // 先发 HTTP 头
sendfile(sockfd, filefd, NULL, fileSize); // 再发文件内容
// 禁用 TCP_CORK:立即把积攒的数据合并成一个 TCP 段发出
optval = 0;
setsockopt(sockfd, IPPROTO_TCP, TCP_CORK, &optval, sizeof(optval));

1.5 TCP 深入理解

TCP 三次握手(连接建立)
客户端                    服务端
  |                          |
  |----SYN M--------------->|  1. 客户端发 SYN,携带初始序号 M
  |                          |
  |<---SYN N, ACK (M+1)-----|  2. 服务端回 SYN+ACK,携带自己初始序号 N,
  |                          |     确认客户端序号 M+1
  |----ACK (N+1)----------->|  3. 客户端确认服务端序号 N+1
  |                          |
  |    连接建立完成           |

三次握手目的:让双方都知道对方已准备好,并同步各自的初始序列号。

TCP 四次挥手(连接终止)
主动关闭方(客户端)        被动关闭方(服务端)
  |                          |
  |----FIN M--------------->|  1. 客户端关闭,发送 FIN
  |                          |     客户端进入 FIN_WAIT1
  |<---ACK (M+1)------------|  2. 服务端确认,进入 CLOSE_WAIT
  |                          |     客户端进入 FIN_WAIT2
  |<---FIN N----------------|  3. 服务端也关闭,发送 FIN
  |                          |     服务端进入 LAST_ACK
  |----ACK (N+1)----------->|  4. 客户端确认,进入 TIME_WAIT
  |                          |     服务端进入 CLOSED
  |  等待 2×MSL 超时         |
  |  进入 CLOSED             |
TIME_WAIT 状态详解

为什么需要 TIME_WAIT?
TIME_WAIT 持续 2 × M S L 2 \times MSL 2×MSL(MSL = 报文最大生存时间,Linux 上为 30 秒,所以 TIME_WAIT 持续 60 秒)。
目的一:可靠终止连接
如果最后一个 ACK 丢失了,被动关闭方会重传 FIN。主动关闭方处于 TIME_WAIT 状态才能重发 ACK 响应。
目的二:让旧报文在网络中过期
防止旧连接遗留的重复报文被新的同地址/端口连接误当成新数据接收。
常见问题:EADDRINUSE 错误
服务器重启时,尝试绑定一个处于 TIME_WAIT 状态的端口会失败。解决方法是设置 SO_REUSEADDR

int optval = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
// 在 bind() 之前调用
TCP 状态机
                    +----------+
                    |  CLOSED  |
                    +----------+
                    /           \
         被动打开                主动打开,发 SYN
               /                     \
        +--------+               +----------+
        | LISTEN |               | SYN_SENT |
        +--------+               +----------+
        收到SYN |                    | 收到SYN+ACK
        发SYN+ACK |                  | 发ACK
               |                     |
        +-----------+           +-------------+
        | SYN_RECV  |           | ESTABLISHED |
        +-----------+           +-------------+
        收到ACK |                    |
               |          应用关闭,发FIN
        +-------------+             |
        | ESTABLISHED |    +------------+
        +-------------+    | FIN_WAIT1  |
               |           +------------+
        收到FIN |           收到ACK |    收到FIN,发ACK
        发ACK   |                   |         |
               |           +------------+  +----------+
        +------------+     | FIN_WAIT2  |  | CLOSING  |
        | CLOSE_WAIT |     +------------+  +----------+
        +------------+     收到FIN,发ACK |  收到ACK |
        应用关闭,发FIN         |                  |
               |           +----------+     +----------+
        +------------+     | TIME_WAIT|     | TIME_WAIT|
        | LAST_ACK   |     +----------+     +----------+
        +------------+     2MSL超时                |
        收到ACK |                   \              /
               |           +----------+   2MSL超时
           +--------+      |  CLOSED  |
           | CLOSED |      +----------+
           +--------+

1.6 完整示例:回显客户端

// echo_client.cpp
// 使用 shutdown(SHUT_WR) 优雅地关闭写端的 TCP 回显客户端
// 编译:g++ -o echo_client echo_client.cpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <cstdio>
#include <cstdlib>
#define BUF_SIZE 256
int main(int argc, char* argv[]) {
    if (argc != 3) {
        fprintf(stderr, "用法: %s <服务器IP> <端口>\n", argv[0]);
        return 1;
    }
    // 创建 TCP 套接字
    int sfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sfd == -1) { perror("socket"); return 1; }
    // 填写服务器地址
    struct sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port   = htons(static_cast<uint16_t>(atoi(argv[2])));
    inet_pton(AF_INET, argv[1], &addr.sin_addr);
    // 连接服务器
    if (connect(sfd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == -1) {
        perror("connect"); return 1;
    }
    // fork 出子进程读取回显,父进程负责发送
    pid_t cpid = fork();
    if (cpid == -1) { perror("fork"); return 1; }
    if (cpid == 0) {
        // ===== 子进程:从套接字读取服务器回显,打印到标准输出 =====
        char buf[BUF_SIZE];
        ssize_t n;
        while ((n = read(sfd, buf, BUF_SIZE)) > 0)
            fwrite(buf, 1, n, stdout);
        return 0;
    }
    // ===== 父进程:从标准输入读取,发送给服务器 =====
    char buf[BUF_SIZE];
    ssize_t n;
    while ((n = read(STDIN_FILENO, buf, BUF_SIZE)) > 0) {
        if (write(sfd, buf, n) != n) {
            fputs("write 失败\n", stderr); return 1;
        }
    }
    // 父进程读到 EOF(用户按了 Ctrl+D),关闭写端
    // 这样服务器看到 EOF,知道客户端发完了,服务器会关闭连接
    // 子进程随后也会收到 EOF,退出
    if (shutdown(sfd, SHUT_WR) == -1) { perror("shutdown"); return 1; }
    return 0;
}

2. 终端编程

2.1 终端驱动概述

终端驱动是内核里管理终端输入输出的组件。它在进程和真实设备之间维护两个队列:

进程 (read/write)
      |           |
      v           ^
  [输出队列]    [输入队列]
      |           |
      v           ^
   终端设备(键盘/屏幕)
如果开启了回显:输入的字符会自动复制到输出队列

2.2 termios 结构体

所有终端设置都存放在一个 termios 结构里:

struct termios {
    tcflag_t c_iflag;     // 输入标志(如:是否把 CR 转换为 NL)
    tcflag_t c_oflag;     // 输出标志(如:是否把 NL 转换为 CR+NL)
    tcflag_t c_cflag;     // 控制标志(如:波特率、字符位数)
    tcflag_t c_lflag;     // 本地标志(如:是否回显、是否规范模式)
    cc_t     c_cc[NCCS];  // 特殊字符数组(如:中断字符、EOF字符)
};

读取和修改终端属性:

#include <termios.h>
// 读取 fd 对应终端的当前属性,存入 termios_p
int tcgetattr(int fd, struct termios* termios_p);
// 设置 fd 对应终端的属性为 termios_p
// optional_actions 决定何时生效:
//   TCSANOW   → 立即生效
//   TCSADRAIN → 等当前输出发完再生效(修改输出相关参数时推荐)
//   TCSAFLUSH → 等当前输出发完,丢弃未读的输入,再生效(读密码时推荐)
int tcsetattr(int fd, int optional_actions, const struct termios* termios_p);

修改终端属性的标准套路(以关闭回显为例):

struct termios tp;
tcgetattr(STDIN_FILENO, &tp);  // 1. 先读取当前设置
tp.c_lflag &= ~ECHO;           // 2. 只改需要改的位
tcsetattr(STDIN_FILENO, TCSAFLUSH, &tp);  // 3. 写回

2.3 规范模式 vs 非规范模式

规范模式(Canonical Mode)—— 默认模式

  • 按行处理输入,用户按回车后应用程序才能读到数据
  • 支持行编辑(退格、删除一行等)
  • 适合:Shell、文本编辑器的命令行
    非规范模式(Non-Canonical Mode)
  • 每个字符立即可读,不需要等回车
  • MINTIME 两个参数控制 read() 何时返回
  • 适合:vi 的编辑模式、游戏、交互程序
    MIN 和 TIME 的四种组合:
    M I N MIN MIN 为最少字节数, T I M E TIME TIME 为超时(单位:1/10 秒):
    read() 行为 = f ( M I N , T I M E ) \text{read() 行为} = f(MIN, TIME) read() 行为=f(MIN,TIME)

MIN TIME 行为
0 0 轮询:有数据就读,没有立即返回 0
>0 0 阻塞:等到至少 MIN 字节
0 >0 超时:有数据就读,或超时返回 0
>0 >0 字节间超时:至少读 1 字节后,相邻字节间隔不超过 TIME/10 秒

2.4 终端特殊字符


字符名 默认按键 功能
INTR Ctrl+C 向前台进程组发送 SIGINT
QUIT Ctrl+\ 向前台进程组发送 SIGQUIT
SUSP Ctrl+Z 向前台进程组发送 SIGTSTP(挂起)
EOF Ctrl+D 规范模式下触发 read() 返回 0
ERASE Backspace 删除上一个字符
KILL Ctrl+U 删除当前整行
STOP Ctrl+S 暂停输出
START Ctrl+Q 恢复输出

2.5 三种终端模式(历史术语)

Cooked(熟)模式 ← 默认,最安全
  - 规范模式
  - 所有特殊字符都有效
  - 输入输出均有处理
Cbreak 模式 ← 字符级输入,但信号仍有效
  - 非规范模式(每个字符可读)
  - 信号字符(Ctrl+C 等)仍有效
  - 通常同时关闭回显
Raw(生)模式 ← 最底层,所有处理都关闭
  - 非规范模式
  - 所有特殊字符处理关闭
  - 输入输出不做任何转换

实现 cbreak 和 raw 模式的完整代码:

// tty_modes.cpp
// 实现 ttySetCbreak() 和 ttySetRaw() 两个函数
// 编译:g++ -o tty_modes tty_modes.cpp
#include <termios.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <csignal>
// 将终端 fd 设置为 cbreak 模式
// prevTermios 非 NULL 时,用于保存原有设置(方便恢复)
// 返回 0 成功,-1 失败
int ttySetCbreak(int fd, struct termios* prevTermios) {
    struct termios t;
    if (tcgetattr(fd, &t) == -1)    // 读取当前设置
        return -1;
    if (prevTermios != nullptr)
        *prevTermios = t;           // 保存原设置
    // 关闭规范模式(ICANON)和回显(ECHO)
    t.c_lflag &= ~(ICANON | ECHO);
    // 保留信号字符处理(ISIG,即 Ctrl+C 等仍然有效)
    t.c_lflag |= ISIG;
    // 关闭 CR→NL 的转换
    t.c_iflag &= ~ICRNL;
    // 每次 read() 读 1 个字符,阻塞等待
    t.c_cc[VMIN]  = 1;
    t.c_cc[VTIME] = 0;
    return tcsetattr(fd, TCSAFLUSH, &t);
}
// 将终端 fd 设置为 raw 模式(关闭所有处理)
int ttySetRaw(int fd, struct termios* prevTermios) {
    struct termios t;
    if (tcgetattr(fd, &t) == -1)
        return -1;
    if (prevTermios != nullptr)
        *prevTermios = t;
    // 关闭:规范模式、信号处理、扩展处理、回显
    t.c_lflag &= ~(ICANON | ISIG | IEXTEN | ECHO);
    // 关闭:BREAK 处理、CR/NL 转换、奇偶校验、输出流控
    t.c_iflag &= ~(BRKINT | ICRNL | IGNBRK | IGNCR |
                   INLCR  | INPCK | ISTRIP | IXON  | PARMRK);
    // 关闭所有输出后处理
    t.c_oflag &= ~OPOST;
    // 每次读 1 字节,阻塞
    t.c_cc[VMIN]  = 1;
    t.c_cc[VTIME] = 0;
    return tcsetattr(fd, TCSAFLUSH, &t);
}
// 演示:进入 raw 模式读字符
int main() {
    struct termios orig;
    if (ttySetRaw(STDIN_FILENO, &orig) == -1) {
        perror("ttySetRaw");
        return 1;
    }
    printf("已进入 raw 模式,按 q 退出\r\n");
    char ch;
    while (read(STDIN_FILENO, &ch, 1) == 1) {
        if (ch == 'q') break;
        // 控制字符显示为 ^X 形式
        if (ch < 32 || ch == 127)
            printf("^%c\r\n", ch ^ 64);
        else
            printf("%c\r\n", ch);
    }
    // 恢复终端设置
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig);
    printf("\n已恢复终端设置\n");
    return 0;
}

2.6 终端窗口大小

当用户调整终端窗口大小时:

  1. 内核向前台进程组发送 SIGWINCH 信号
  2. 进程用 ioctl(fd, TIOCGWINSZ, &ws) 获取新的窗口大小
#include <sys/ioctl.h>
#include <termios.h>
#include <csignal>
#include <cstdio>
#include <unistd.h>
// 信号处理函数:窗口大小改变时被调用
void sigwinchHandler(int sig) {
    // 这里什么都不做,只是让 pause() 返回
}
int main() {
    struct sigaction sa{};
    sa.sa_handler = sigwinchHandler;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGWINCH, &sa, nullptr);
    for (;;) {
        pause();   // 等待信号
        struct winsize ws;
        if (ioctl(STDIN_FILENO, TIOCGWINSZ, &ws) == -1) {
            perror("ioctl");
            break;
        }
        printf("窗口大小改变:%d 行 × %d 列\n", ws.ws_row, ws.ws_col);
    }
    return 0;
}

3. 替代 I/O 模型

3.1 为什么需要替代 I/O 模型?

传统阻塞 I/O 的问题:
一个 read() 在没有数据时会阻塞,导致程序什么都做不了。如果需要同时监控多个文件描述符(比如同时和 100 个客户端通话),就麻烦了。
三种解决方案对比:

方案一:I/O 多路复用(select/poll)
  优点:可移植,标准化
  缺点:监控大量 fd 时性能差
方案二:信号驱动 I/O(Signal-Driven I/O)
  优点:内核记住要监控的 fd,性能好
  缺点:信号处理复杂,可能丢通知
方案三:epoll(Linux 特有)
  优点:性能最好,支持边缘/水平触发
  缺点:只有 Linux 支持

3.2 水平触发 vs 边缘触发

这是理解 epoll 的关键概念,用一个比喻来解释:
水平触发(Level-Triggered):就像水位报警器——只要水位超过警戒线,就一直报警。只要数据还在缓冲区,每次调用都会通知你"数据可读"。
边缘触发(Edge-Triggered):就像门铃——只在有人按门铃的那一刻响一次。只在有新数据到达时通知一次,之后不再通知,直到又有新数据。

水平触发:
  时间线: -----[数据到达]--------------------[数据被读走]---
  通知:   -----[!][!][!][!][!][!][!][!][!][!]--------------
  (每次 epoll_wait 都通知,直到数据被读走)
边缘触发:
  时间线: -----[数据到达]--------------------[数据被读走]---
  通知:   -----[!]-------------------------------------------
  (只在数据到达那一刻通知一次)

机制 触发模式
select() / poll() 仅水平触发
信号驱动 I/O 仅边缘触发
epoll 两者都支持(默认水平触发)

3.3 select() 系统调用

select() 让你同时等待多个文件描述符变为可读/可写/异常。

#include <sys/select.h>
int select(int nfds,
           fd_set* readfds,    // 监控这些 fd 是否可读
           fd_set* writefds,   // 监控这些 fd 是否可写
           fd_set* exceptfds,  // 监控这些 fd 是否有异常
           struct timeval* timeout);  // 超时时间,NULL 表示永久阻塞
// 返回值:就绪的 fd 数量,0 超时,-1 出错

fd_set 操作宏:

fd_set readfds;
FD_ZERO(&readfds);         // 清空集合
FD_SET(fd, &readfds);      // 把 fd 加入集合
FD_CLR(fd, &readfds);      // 把 fd 从集合移除
FD_ISSET(fd, &readfds);    // 检查 fd 是否在集合中(且已就绪)

完整 select 示例:

// select_demo.cpp
// 同时监控标准输入和一个管道,哪个先有数据就先读哪个
// 编译:g++ -o select_demo select_demo.cpp
#include <sys/select.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>
#include <cerrno>
int main() {
    // 创建一个管道用于演示
    int pfd[2];
    if (pipe(pfd) == -1) { perror("pipe"); return 1; }
    // 向管道写端写入一些数据(实际场景中可能是另一个进程写)
    write(pfd[1], "来自管道的数据", 7);
    close(pfd[1]);  // 关闭写端
    fd_set readfds;
    struct timeval timeout;
    // 设置超时为 5 秒
    timeout.tv_sec  = 5;
    timeout.tv_usec = 0;
    // 构建监控集合:监控标准输入和管道读端
    FD_ZERO(&readfds);
    FD_SET(STDIN_FILENO, &readfds);   // fd 0
    FD_SET(pfd[0], &readfds);         // 管道读端
    // nfds = 最大 fd + 1
    int nfds = pfd[0] + 1;
    printf("等待输入(5秒超时)...\n");
    int ready = select(nfds, &readfds, nullptr, nullptr, &timeout);
    if (ready == -1) {
        perror("select");
        return 1;
    } else if (ready == 0) {
        printf("超时,没有数据\n");
    } else {
        printf("有 %d 个 fd 就绪\n", ready);
        if (FD_ISSET(STDIN_FILENO, &readfds)) {
            char buf[256];
            ssize_t n = read(STDIN_FILENO, buf, sizeof(buf));
            printf("标准输入:%.*s\n", (int)n, buf);
        }
        if (FD_ISSET(pfd[0], &readfds)) {
            char buf[256];
            ssize_t n = read(pfd[0], buf, sizeof(buf));
            printf("管道数据:%.*s\n", (int)n, buf);
        }
    }
    close(pfd[0]);
    return 0;
}

3.4 poll() 系统调用

poll()select() 功能类似,但接口设计更清晰——用一个结构体数组代替三个 fd_set,没有最大 fd 数量限制(FD_SETSIZE = 1024)。

#include <poll.h>
struct pollfd {
    int   fd;       // 要监控的文件描述符
    short events;   // 感兴趣的事件(输入参数)
    short revents;  // 实际发生的事件(输出参数)
};
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
// timeout: -1=永久阻塞, 0=立即返回, >0=超时毫秒数

events/revents 常用标志:

标志 含义
POLLIN 有数据可读
POLLOUT 可以写入数据
POLLPRI 有高优先级数据(带外数据)
POLLERR 发生错误(仅出现在 revents)
POLLHUP 挂起(对端关闭,仅出现在 revents)
POLLNVAL fd 无效(仅出现在 revents)
POLLRDHUP 对端套接字关闭了写端(Linux 2.6.17+)

3.5 epoll API

epoll 是 Linux 专有的高性能 I/O 通知机制,特别适合需要同时监控成千上万个连接的服务器。
核心思想:

  • select/poll 每次调用都要把所有 fd 传给内核再传回来
  • epoll 只需一次注册,内核记住哪些 fd 要监控,之后只返回就绪的 fd
    三个核心 API:
#include <sys/epoll.h>
// 1. 创建 epoll 实例,返回 epoll fd
int epoll_create(int size);   // size 参数现在被忽略,传一个正整数即可
// 2. 向 epoll 兴趣列表添加/修改/删除 fd
int epoll_ctl(int epfd,       // epoll 实例的 fd
              int op,         // EPOLL_CTL_ADD / EPOLL_CTL_MOD / EPOLL_CTL_DEL
              int fd,         // 要操作的目标 fd
              struct epoll_event* ev);  // 事件描述
// 3. 等待事件,返回就绪 fd 列表
int epoll_wait(int epfd,
               struct epoll_event* evlist,  // 就绪事件数组(调用者分配)
               int maxevents,              // 数组大小
               int timeout);              // -1=永久, 0=立即, >0=毫秒

epoll_event 结构:

struct epoll_event {
    uint32_t events;      // 要监控的事件(EPOLLIN, EPOLLOUT, EPOLLET 等)
    epoll_data_t data;    // 用户自定义数据,随事件一起返回
};
typedef union epoll_data {
    void*    ptr;   // 可以存指针
    int      fd;    // 通常存 fd 编号
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

完整 epoll 服务器示例:

// epoll_server.cpp
// 使用 epoll 实现一个可以同时处理多个客户端的回显服务器
// 编译:g++ -o epoll_server epoll_server.cpp
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
#include <cstdio>
#include <cerrno>
#define MAX_EVENTS 64
#define BUF_SIZE   1024
#define PORT       8888
// 将文件描述符设置为非阻塞模式
int setNonBlocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
    // 1. 创建监听套接字
    int listenFd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenFd == -1) { perror("socket"); return 1; }
    // 允许端口复用(避免 TIME_WAIT 导致绑定失败)
    int optval = 1;
    setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
    struct sockaddr_in addr{};
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port        = htons(PORT);
    if (bind(listenFd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == -1) {
        perror("bind"); return 1;
    }
    if (listen(listenFd, 10) == -1) { perror("listen"); return 1; }
    setNonBlocking(listenFd);
    // 2. 创建 epoll 实例
    int epfd = epoll_create(1);
    if (epfd == -1) { perror("epoll_create"); return 1; }
    // 3. 把监听套接字加入 epoll 兴趣列表
    struct epoll_event ev{};
    ev.events  = EPOLLIN;            // 监控可读事件(有新连接)
    ev.data.fd = listenFd;
    if (epoll_ctl(epfd, EPOLL_CTL_ADD, listenFd, &ev) == -1) {
        perror("epoll_ctl"); return 1;
    }
    printf("服务器在端口 %d 监听中...\n", PORT);
    struct epoll_event evlist[MAX_EVENTS];
    char buf[BUF_SIZE];
    // 4. 事件循环
    for (;;) {
        // 等待事件(-1 表示永久阻塞)
        int nready = epoll_wait(epfd, evlist, MAX_EVENTS, -1);
        if (nready == -1) {
            if (errno == EINTR) continue;   // 被信号打断,重试
            perror("epoll_wait");
            break;
        }
        // 处理所有就绪事件
        for (int i = 0; i < nready; i++) {
            int fd = evlist[i].data.fd;
            if (fd == listenFd) {
                // === 有新客户端连接 ===
                struct sockaddr_in cliAddr{};
                socklen_t cliLen = sizeof(cliAddr);
                int connFd = accept(listenFd,
                                    reinterpret_cast<sockaddr*>(&cliAddr),
                                    &cliLen);
                if (connFd == -1) { perror("accept"); continue; }
                setNonBlocking(connFd);   // 客户端 fd 设为非阻塞
                // 把新连接的 fd 加入 epoll 监控
                struct epoll_event cev{};
                cev.events  = EPOLLIN | EPOLLET;  // 边缘触发
                cev.data.fd = connFd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, connFd, &cev);
                printf("新客户端连接,fd=%d\n", connFd);
            } else if (evlist[i].events & EPOLLIN) {
                // === 已连接的客户端有数据可读 ===
                // 边缘触发模式下,必须循环读到 EAGAIN
                for (;;) {
                    ssize_t n = read(fd, buf, BUF_SIZE);
                    if (n == -1) {
                        if (errno == EAGAIN) break;  // 数据读完了
                        perror("read");
                        break;
                    } else if (n == 0) {
                        // 客户端断开连接
                        printf("客户端 fd=%d 断开\n", fd);
                        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
                        close(fd);
                        break;
                    }
                    // 回显:原样发回客户端
                    write(fd, buf, n);
                }
            }
        }
    }
    close(listenFd);
    close(epfd);
    return 0;
}

3.6 性能对比

下面是监控 N 个文件描述符、每次只有 1 个就绪时,100000 次调用所花的 CPU 时间:
T s e l e c t ( N ) ≈ O ( N ) , T p o l l ( N ) ≈ O ( N ) , T e p o l l ( N ) ≈ O ( 1 ) T_{select}(N) \approx O(N), \quad T_{poll}(N) \approx O(N), \quad T_{epoll}(N) \approx O(1) Tselect(N)O(N),Tpoll(N)O(N),Tepoll(N)O(1)

监控 fd 数量 (N) poll() 耗时(秒) select() 耗时(秒) epoll 耗时(秒)
10 0.61 0.73 0.41
100 2.9 3.0 0.42
1000 35 35 0.53
10000 990 930 0.66

为什么 epoll 这么快?

  • select/poll:每次调用都要把所有 fd 从用户空间复制到内核,内核检查完再复制回来
  • epoll:只需一次 epoll_ctl 注册,内核在 I/O 事件发生时主动把就绪 fd 放入就绪列表,epoll_wait 只取就绪列表,无需扫描全部 fd

3.7 信号与文件描述符的同时等待:Self-Pipe 技巧

问题: select() 只能等 fd 就绪,不能直接等信号。如果信号在 select() 之前到达,就会错过。
Self-Pipe 技巧(自管道技巧):
核心思路:创建一个管道,信号处理函数向管道写端写一个字节,然后把管道读端加入 select() 监控集合。

信号到来 → 信号处理函数 → write(pipe写端, "x", 1)
                                    ↓
select() 监控 pipe读端 → 检测到可读 → 知道有信号了

实现代码:

// self_pipe.cpp
// 演示 Self-Pipe 技巧:同时等待信号(SIGINT)和文件描述符就绪
// 编译:g++ -o self_pipe self_pipe.cpp
#include <sys/select.h>
#include <unistd.h>
#include <fcntl.h>
#include <csignal>
#include <cerrno>
#include <cstdio>
static int pfd[2];  // 全局管道:pfd[0] 读端,pfd[1] 写端
// 信号处理函数:向管道写一个字节
void sigHandler(int sig) {
    int savedErrno = errno;           // 保存 errno,write() 可能改变它
    // 写入失败(如管道满了)也没关系,之前的写入已经表示有信号了
    write(pfd[1], "x", 1);
    errno = savedErrno;               // 恢复 errno
}
int main() {
    // 1. 创建管道,两端都设为非阻塞
    if (pipe(pfd) == -1) { perror("pipe"); return 1; }
    // 设置读端非阻塞
    int flags = fcntl(pfd[0], F_GETFL);
    fcntl(pfd[0], F_SETFL, flags | O_NONBLOCK);
    // 设置写端非阻塞(防止信号太多把管道写满导致阻塞)
    flags = fcntl(pfd[1], F_GETFL);
    fcntl(pfd[1], F_SETFL, flags | O_NONBLOCK);
    // 2. 安装信号处理函数(在创建管道之后)
    struct sigaction sa{};
    sa.sa_handler = sigHandler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;         // 被信号打断后自动重启系统调用
    sigaction(SIGINT, &sa, nullptr);
    printf("等待 Ctrl+C 或标准输入...\n");
    for (;;) {
        fd_set readfds;
        FD_ZERO(&readfds);
        FD_SET(STDIN_FILENO, &readfds);    // 监控标准输入
        FD_SET(pfd[0], &readfds);          // 监控管道读端(用于感知信号)
        int nfds = pfd[0] + 1;
        // 3. 调用 select()(被信号打断后通过 SA_RESTART 重试)
        int ready = select(nfds, &readfds, nullptr, nullptr, nullptr);
        if (ready == -1 && errno == EINTR) continue;
        if (ready == -1) { perror("select"); break; }
        // 4. 检查是否是信号触发的
        if (FD_ISSET(pfd[0], &readfds)) {
            char ch;
            // 把管道里的字节都读完(可能多个信号同时到达)
            while (read(pfd[0], &ch, 1) > 0)
                ;
            printf("\n收到 SIGINT 信号!\n");
            break;
        }
        // 5. 检查标准输入
        if (FD_ISSET(STDIN_FILENO, &readfds)) {
            char buf[256];
            ssize_t n = read(STDIN_FILENO, buf, sizeof(buf));
            if (n <= 0) break;
            printf("读到输入:%.*s", (int)n, buf);
        }
    }
    close(pfd[0]);
    close(pfd[1]);
    return 0;
}

4. 伪终端

4.1 为什么需要伪终端?

问题场景: 想让一个程序(如 vi)通过网络(如 SSH)远程运行,但 vi 只认识"终端",不认识"网络套接字"。
解决方案:用伪终端作为中间层

SSH客户端 ←→ 网络 ←→ SSH服务端
                           |
                      [伪终端主设备](连接网络侧)
                           |
                      [伪终端从设备](看起来像普通终端)
                           |
                         Shell / vi 等程序

伪终端从设备(slave)看起来和真实终端一模一样,vi 感受不到任何区别。

4.2 主设备和从设备的关系

数据流向:
驱动程序写 → [主设备] → 从设备看到键盘输入
从设备写   → [从设备] → 驱动程序从主设备读到
终端处理(echo、信号、行编辑等)发生在从设备一侧

4.3 UNIX 98 伪终端 API

步骤1: posix_openpt()  → 打开一个空闲的主设备,返回 mfd
步骤2: grantpt(mfd)    → 设置从设备的权限(某些系统需要)
步骤3: unlockpt(mfd)   → 解锁从设备,允许打开
步骤4: ptsname(mfd)    → 获取从设备的路径名(如 /dev/pts/5)
步骤5: open(slaveName) → 打开从设备
// pty_open.cpp
// 演示如何打开一个伪终端对
// 编译:g++ -o pty_open pty_open.cpp
#define _XOPEN_SOURCE 600
#include <stdlib.h>
#include <fcntl.h>
#include <cstdio>
#include <cstring>
#include <unistd.h>
int main() {
    // 1. 打开伪终端主设备
    int masterFd = posix_openpt(O_RDWR | O_NOCTTY);
    if (masterFd == -1) { perror("posix_openpt"); return 1; }
    // 2. 设置从设备权限
    if (grantpt(masterFd) == -1) { perror("grantpt"); return 1; }
    // 3. 解锁从设备
    if (unlockpt(masterFd) == -1) { perror("unlockpt"); return 1; }
    // 4. 获取从设备路径名
    char* slaveName = ptsname(masterFd);
    if (slaveName == nullptr) { perror("ptsname"); return 1; }
    printf("伪终端主设备 fd: %d\n", masterFd);
    printf("伪终端从设备路径: %s\n", slaveName);
    // 5. 打开从设备
    int slaveFd = open(slaveName, O_RDWR);
    if (slaveFd == -1) { perror("open slave"); return 1; }
    printf("伪终端从设备 fd: %d\n", slaveFd);
    // 演示:向主设备写入,从设备能读到
    write(masterFd, "hello\n", 6);
    char buf[256];
    ssize_t n = read(slaveFd, buf, sizeof(buf));
    printf("从设备读到: %.*s", (int)n, buf);
    close(slaveFd);
    close(masterFd);
    return 0;
}

4.4 ptyFork() 实现

ptyFork() 把建立伪终端、创建子进程、连接标准 I/O 的全套操作封装成一个函数:

ptyFork() 执行流程:
父进程                          子进程
  |                               |
  |-- ptyMasterOpen() ---------> 继承 mfd(然后关闭)
  |                               |
  |<-- fork() ------------------> fork()
  |                               |
  |   返回 masterFd 给调用者       setsid()  ← 新会话,失去控制终端
  |                               |
  |                               open(slaveName) ← 从设备成为控制终端
  |                               |
  |                               dup2(slaveFd, STDIN)
  |                               dup2(slaveFd, STDOUT)
  |                               dup2(slaveFd, STDERR)
  |                               |
  |                               exec(目标程序)

4.5 script 程序工作原理

script(1) 是伪终端的经典应用——它记录整个 Shell 会话。

用户终端 (raw模式)
    |        ^
    |        |  (script 进程作为中转)
    v        |
[伪终端主设备]  ← 这里同时写入 typescript 文件
    |        ^
    |        |  (Shell 和终端特性处理在从设备这里发生)
    v        |
[伪终端从设备]
    |        ^
    |        |
    v        |
  Shell 进程

整个过程用 select() 同时监控两个方向:

  • 用户终端 → 主设备(用户的输入转发给 Shell)
  • 主设备 → 用户终端 + 文件(Shell 的输出显示并记录)

4.6 完整的 script 实现

// simple_script.cpp
// script(1) 的简化实现:记录 Shell 会话到文件
// 编译:g++ -o simple_script simple_script.cpp -lutil
// 注意:需要 Linux 系统,以 root 或有相应权限的用户运行
#define _XOPEN_SOURCE 600
#include <sys/select.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <termios.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstdlib>
#include <cstdio>
#include <cstring>
#include <cerrno>
#include <csignal>
#define BUF_SIZE   256
#define MAX_SNAME  1000
static struct termios g_ttyOrig;   // 保存原始终端设置
// 退出时恢复终端设置
void ttyReset() {
    tcsetattr(STDIN_FILENO, TCSANOW, &g_ttyOrig);
}
// 将 fd 设为非规范模式(raw)
int ttySetRaw(int fd, struct termios* prevTermios) {
    struct termios t;
    if (tcgetattr(fd, &t) == -1) return -1;
    if (prevTermios) *prevTermios = t;
    t.c_lflag &= ~(ICANON | ISIG | IEXTEN | ECHO);
    t.c_iflag &= ~(BRKINT | ICRNL | IGNBRK | IGNCR |
                   INLCR  | INPCK | ISTRIP | IXON  | PARMRK);
    t.c_oflag &= ~OPOST;
    t.c_cc[VMIN]  = 1;
    t.c_cc[VTIME] = 0;
    return tcsetattr(fd, TCSAFLUSH, &t);
}
int main(int argc, char* argv[]) {
    const char* scriptFile = (argc > 1) ? argv[1] : "typescript";
    char slaveName[MAX_SNAME];
    // 1. 保存当前终端设置和窗口大小
    if (tcgetattr(STDIN_FILENO, &g_ttyOrig) == -1) {
        perror("tcgetattr"); return 1;
    }
    struct winsize ws;
    ioctl(STDIN_FILENO, TIOCGWINSZ, &ws);
    // 2. 打开伪终端主设备
    int masterFd = posix_openpt(O_RDWR | O_NOCTTY);
    if (masterFd == -1) { perror("posix_openpt"); return 1; }
    grantpt(masterFd);
    unlockpt(masterFd);
    char* pname = ptsname(masterFd);
    if (!pname) { perror("ptsname"); return 1; }
    strncpy(slaveName, pname, MAX_SNAME - 1);
    // 3. fork 出子进程
    pid_t childPid = fork();
    if (childPid == -1) { perror("fork"); return 1; }
    if (childPid == 0) {
        // ===== 子进程:启动 Shell,连接到伪终端从设备 =====
        close(masterFd);  // 子进程不需要主设备
        if (setsid() == -1) { perror("setsid"); return 1; }  // 新会话
        int slaveFd = open(slaveName, O_RDWR);  // 从设备成为控制终端
        if (slaveFd == -1) { perror("open slave"); return 1; }
        // 设置从设备与原始终端相同的属性和窗口大小
        tcsetattr(slaveFd, TCSANOW, &g_ttyOrig);
        ioctl(slaveFd, TIOCSWINSZ, &ws);
        // 把从设备连接到标准 I/O
        dup2(slaveFd, STDIN_FILENO);
        dup2(slaveFd, STDOUT_FILENO);
        dup2(slaveFd, STDERR_FILENO);
        if (slaveFd > STDERR_FILENO) close(slaveFd);
        // 启动 Shell
        const char* shell = getenv("SHELL");
        if (!shell || *shell == '\0') shell = "/bin/sh";
        execlp(shell, shell, nullptr);
        perror("execlp");
        return 1;
    }
    // ===== 父进程:在终端和伪终端主设备之间中转数据 =====
    // 4. 打开记录文件
    int scriptFd = open(scriptFile,
                        O_WRONLY | O_CREAT | O_TRUNC,
                        S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
    if (scriptFd == -1) { perror("open script"); return 1; }
    // 5. 把用户终端设为 raw 模式
    if (ttySetRaw(STDIN_FILENO, nullptr) == -1) {
        perror("ttySetRaw"); return 1;
    }
    atexit(ttyReset);  // 注册退出清理函数
    // 6. 数据中转循环
    char buf[BUF_SIZE];
    fd_set inFds;
    for (;;) {
        FD_ZERO(&inFds);
        FD_SET(STDIN_FILENO, &inFds);   // 监控用户终端输入
        FD_SET(masterFd, &inFds);       // 监控伪终端主设备输入
        if (select(masterFd + 1, &inFds, nullptr, nullptr, nullptr) == -1) {
            if (errno == EINTR) continue;
            break;
        }
        // 用户输入 → 转发给 Shell(通过伪终端主设备)
        if (FD_ISSET(STDIN_FILENO, &inFds)) {
            ssize_t n = read(STDIN_FILENO, buf, BUF_SIZE);
            if (n <= 0) break;
            write(masterFd, buf, n);
        }
        // Shell 输出 → 显示到终端 + 写入记录文件
        if (FD_ISSET(masterFd, &inFds)) {
            ssize_t n = read(masterFd, buf, BUF_SIZE);
            if (n <= 0) break;
            write(STDOUT_FILENO, buf, n);    // 显示
            write(scriptFd, buf, n);         // 记录
        }
    }
    close(masterFd);
    close(scriptFd);
    printf("\n脚本已保存到: %s\n", scriptFile);
    return 0;
}

4.7 BSD 伪终端(历史参考)

UNIX 98 伪终端之前,BSD 系统用预创建的设备文件:

  • 主设备:/dev/ptyXY(X 是字母 p-e,Y 是 0-9a-f)
  • 从设备:/dev/ttyXY
    查找空闲主设备的方法是逐个尝试 open(),直到成功或遇到 ENOENT(设备不存在)。
    现在 UNIX 98 风格(/dev/ptmx + /dev/pts/N)已经是标准,新代码不应使用 BSD 风格。

综合总结

各技术适用场景


技术 适用场景 主要优点 主要缺点
select() 需要跨平台,fd 数量少 可移植性好 fd 上限 1024,性能差
poll() 需要跨平台,fd 数量中等 无 fd 上限,API 更清晰 大量 fd 时性能差
epoll Linux 服务器,大量连接 性能卓越,支持边缘触发 仅 Linux
信号驱动 I/O 不常用 性能好 信号处理复杂
伪终端 终端仿真、SSH、script 让程序以为在真实终端运行 实现复杂

关键公式回顾

TCP TIME_WAIT 持续时间: 2 × M S L 2 \times MSL 2×MSL
在 Linux 上, M S L = 30 MSL = 30 MSL=30 秒,所以:
T T I M E _ W A I T = 2 × 30 = 60  秒 T_{TIME\_WAIT} = 2 \times 30 = 60 \text{ 秒} TTIME_WAIT=2×30=60 
epoll 时间复杂度(每次 epoll_wait 调用):
T e p o l l = O ( 就绪事件数 ) T_{epoll} = O(\text{就绪事件数}) Tepoll=O(就绪事件数)
select/poll 时间复杂度(每次调用):
T s e l e c t = T p o l l = O ( N ) , N = 监控的 fd 数量 T_{select} = T_{poll} = O(N), \quad N = \text{监控的 fd 数量} Tselect=Tpoll=O(N),N=监控的 fd 数量
笔记整理自《Linux/UNIX 系统编程手册》第 61、62、63、64 章

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐