The Linux Programming interface学习:POSIX IPC 详解:消息队列与信号量
一、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=10,mq_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) 可以主动取消注册
通知流程图:
方式一:通过信号通知(完整示例):
// 编译: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返回
- 被信号中断时返回
EINTR(SA_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 对比
| 特性 | System V 信号量 | POSIX 命名信号量 | POSIX 未命名信号量 | Mutex |
|---|---|---|---|---|
| 接口复杂度 | 高 | 中 | 低 | 低 |
| 操作单位 | 集合(多个信号量原子操作) | 单个 | 单个 | 单个 |
| 操作量 | 任意整数 | ±1 | ±1 | 锁/解锁 |
| 等待值为0 | 支持 | 不支持 | 不支持 | 不支持 |
| 引用计数 | 否 | 是 | N/A | N/A |
| 初始化竞争 | 有(见第47章) | 无(原子创建+初始化) | 无 | 无 |
| 信号处理安全 | 否 | sem_post 是 | sem_post 是 | 否 |
| 所有权语义 | 无 | 无 | 无 | 有(只有锁者能解锁) |
| 低竞争性能 | 差(每次都系统调用) | 好(futex,无竞争不需系统调用) | 好 | 好 |
| 可移植性 | 最好 | 较好(Linux 2.6+) | 较好 | 最好 |
五、POSIX IPC 知识总图
六、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 共享内存
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() 的局限性
- 只能锁整个文件,粒度太粗
- 只有建议性锁(advisory),进程可以无视
- 很多 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_len−1]
当 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
字段含义(从左到右):
- 锁序号
- 类型:
POSIX(fcntl 锁)或FLOCK(flock 锁) - 模式:
ADVISORY(建议性)或MANDATORY(强制性) - 锁类型:
READ或WRITE - 持锁进程 PID
主设备号:次设备号:inode号(标识文件)- 锁起始字节
- 锁结束字节(
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
domain:AF_UNIX/AF_INET/AF_INET6type:SOCK_STREAM或SOCK_DGRAMprotocol:通常填 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域套接字
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()会在文件系统中创建一个特殊文件(类型s,ls -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_STREAM或SOCK_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地址=网络ID(Network ID)+主机ID(Host 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/24⇒前24位是网络ID,后8位是主机ID
可用主机地址数(全0和全1不可用):
可用地址数 = 2 32 − 前缀长度 − 2 \text{可用地址数} = 2^{32 - \text{前缀长度}} - 2 可用地址数=232−前缀长度−2
对于 /24: 2 8 − 2 = 254 2^8 - 2 = 254 28−2=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 基础上只增加了两件事:
- 端口号(区分应用)
- 数据校验和(检测错误)
UDP 依然是无连接、不可靠的。适合:DNS查询、视频流、游戏等对延迟敏感但能容忍丢包的场景。
UDP 避免IP分片的建议:
IPv4 最小重组缓冲区 = 576 字节,减去 IP 头(最小20字节)和 UDP 头(8字节):
安全UDP数据大小 = 576 − 20 − 8 = 548 字节 \text{安全UDP数据大小} = 576 - 20 - 8 = 548 \text{ 字节} 安全UDP数据大小=576−20−8=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)
防止压垮网络,分两个算法:
- 慢启动(Slow Start):连接初期,拥塞窗口 W c W_c Wc 指数增长
- 拥塞避免(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 层次结构:
域名解析过程(迭代解析)
以查询 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()后,lfd和cfd在父子进程中都有副本- 父进程必须关闭
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) |
| 协议 | tcp 或 udp |
| 标志 | 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域套接字:
- 地址是文件路径,
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. 套接字高级主题
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() 系统调用
问题背景:
传统做法把文件发送到套接字需要两步:
read()把文件从内核缓冲区读到用户空间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) - 每个字符立即可读,不需要等回车
- 由
MIN和TIME两个参数控制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 终端窗口大小
当用户调整终端窗口大小时:
- 内核向前台进程组发送
SIGWINCH信号 - 进程用
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 章
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)