第三十四章:共享之秘
筑基后期涉及内核源码:
一
林小源在研究管道之后,发现了一种更快的进程间通信方式:共享内存。
管道的数据需要从用户态复制到内核态(write()),再从内核态复制到另一个进程的用户态(read())。两次复制。但共享内存不同——两个进程直接访问同一块物理内存,零复制。
他走进一间透明的房间。房间的墙壁是玻璃做的,两个进程站在房间两侧,中间是一块发光的物理页。左边的进程往物理页上写字,右边的进程立刻看到了。
"你们不需要复制?"林小源问。
"不需要。"左边的进程说,"这块物理页映射到了我们两个的地址空间里。我的虚拟地址和它的虚拟地址不同,但我们指向同一块物理内存。我写什么,它立刻看到什么。"
"这是最快的通信方式。"右边的进程补充道,"管道需要两次复制——用户态到内核态,内核态到用户态。我们只需要一次映射,之后就是直接读写。"
/*
* 共享内存的原理:
* 1. shmget() 创建共享内存段
* 2. shmat() 将共享内存映射到进程的地址空间
* 3. 多个进程映射同一块共享内存
* 4. 进程直接读写共享内存——零复制
*
* 共享内存是最快的 IPC 方式:
* - 管道: 用户态 → 内核态 → 用户态 (两次复制)
* - 共享内存: 用户态 ↔ 用户态 (零复制)
*
* 但共享内存需要同步机制:
* - 多个进程同时读写 → 竞态条件
* - 需要信号量、互斥锁等同步原语
*/
struct shared_data {
int counter;
char message[256];
int ready; /* 同步标志 */
};
printf("=== 共享内存 — 零复制通信 ===\n\n");
/* 模拟共享内存 */
struct shared_data shm = { .counter = 0, .ready = 0 };
strcpy(shm.message, "");
printf("--- 进程 A 写入共享内存 ---\n");
shm.counter = 42;
strcpy(shm.message, "Hello from Process A");
shm.ready = 1;
printf("[进程 A] counter = %d\n", shm.counter);
printf("[进程 A] message = %s\n", shm.message);
printf("[进程 A] ready = %d\n\n", shm.ready);
printf("--- 进程 B 读取共享内存 ---\n");
if (shm.ready) {
printf("[进程 B] counter = %d\n", shm.counter);
printf("[进程 B] message = %s\n", shm.message);
}
printf("\n--- 共享内存 vs 管道 ---\n");
printf("管道:\n");
printf(" write() → 内核缓冲区 → read()\n");
printf(" 两次数据复制\n");
printf(" 适合:流式数据、单向通信\n\n");
printf("共享内存:\n");
printf(" 进程 A ↔ 物理页 ↔ 进程 B\n");
printf(" 零数据复制\n");
printf(" 适合:大量数据、频繁通信\n");
printf(" 需要同步机制\n");
printf("\n--- 共享内存的内核实现 ---\n");
printf("1. shmget() — 创建共享内存段\n");
printf(" 内核分配物理页,创建 shmid_ds 结构\n\n");
printf("2. shmat() — 映射到进程地址空间\n");
printf(" 修改进程的页表,指向共享内存的物理页\n\n");
printf("3. 多个进程映射同一块共享内存\n");
printf(" 不同的虚拟地址,相同的物理页\n\n");
printf("4. shmdt() — 解除映射\n");
printf(" 删除进程页表中的映射\n\n");
printf("5. shmctl() — 控制共享内存\n");
printf(" 删除共享内存段、获取状态等\n");#include <stdio.h>
#include <string.h>
/*
* 共享内存的原理:
* 1. shmget() 创建共享内存段
* 2. shmat() 将共享内存映射到进程的地址空间
* 3. 多个进程映射同一块共享内存
* 4. 进程直接读写共享内存——零复制
*
* 共享内存是最快的 IPC 方式:
* - 管道: 用户态 → 内核态 → 用户态 (两次复制)
* - 共享内存: 用户态 ↔ 用户态 (零复制)
*
* 但共享内存需要同步机制:
* - 多个进程同时读写 → 竞态条件
* - 需要信号量、互斥锁等同步原语
*/
struct shared_data {
int counter;
char message[256];
int ready; /* 同步标志 */
};
int main() {
printf("=== 共享内存 — 零复制通信 ===\n\n");
/* 模拟共享内存 */
struct shared_data shm = { .counter = 0, .ready = 0 };
strcpy(shm.message, "");
printf("--- 进程 A 写入共享内存 ---\n");
shm.counter = 42;
strcpy(shm.message, "Hello from Process A");
shm.ready = 1;
printf("[进程 A] counter = %d\n", shm.counter);
printf("[进程 A] message = %s\n", shm.message);
printf("[进程 A] ready = %d\n\n", shm.ready);
printf("--- 进程 B 读取共享内存 ---\n");
if (shm.ready) {
printf("[进程 B] counter = %d\n", shm.counter);
printf("[进程 B] message = %s\n", shm.message);
}
printf("\n--- 共享内存 vs 管道 ---\n");
printf("管道:\n");
printf(" write() → 内核缓冲区 → read()\n");
printf(" 两次数据复制\n");
printf(" 适合:流式数据、单向通信\n\n");
printf("共享内存:\n");
printf(" 进程 A ↔ 物理页 ↔ 进程 B\n");
printf(" 零数据复制\n");
printf(" 适合:大量数据、频繁通信\n");
printf(" 需要同步机制\n");
printf("\n--- 共享内存的内核实现 ---\n");
printf("1. shmget() — 创建共享内存段\n");
printf(" 内核分配物理页,创建 shmid_ds 结构\n\n");
printf("2. shmat() — 映射到进程地址空间\n");
printf(" 修改进程的页表,指向共享内存的物理页\n\n");
printf("3. 多个进程映射同一块共享内存\n");
printf(" 不同的虚拟地址,相同的物理页\n\n");
printf("4. shmdt() — 解除映射\n");
printf(" 删除进程页表中的映射\n\n");
printf("5. shmctl() — 控制共享内存\n");
printf(" 删除共享内存段、获取状态等\n");
return 0;
}最快的通信,是"不通信"——直接共享。
二
林小源在研究共享内存的过程中,撞上了并发编程的核心难题。
如果两个进程同时修改共享内存中的同一个变量,会发生什么?
他站在那间透明的房间外面,看到两个进程同时伸手去写那块物理页上的同一个位置。左边的进程写了 6,右边的进程写了 7。最终那个位置的值是 7——但左边的进程不知道自己的修改被覆盖了。
"你们同时写了!"林小源喊道。
两个进程都愣住了。
"我读到的是 5。"左边的进程说。
"我也读到的是 5。"右边的进程说。
"你改成了 6?"
"你改成了 7?"
"最终是 7。"林小源说,"你的修改丢失了。"
两个进程面面相觑。
"这就是竞态条件。"一个低沉的声音从旁边传来。林小源转头,看到一个计数器形状的存在——信号量。它有两个操作:sem_wait() 和 sem_post()。
/*
* 信号量 (Semaphore) 的作用:
* 控制多个进程对共享资源的访问。
*
* 信号量是一个计数器:
* - sem_wait(): 计数器减 1,如果为 0 则阻塞
* - sem_post(): 计数器加 1,唤醒等待的进程
* - sem_trywait(): 非阻塞版本
*
* 互斥信号量 (Mutex):
* 计数器只有 0 和 1 两个值
* 用于保护临界区(同一时间只有一个进程能进入)
*/
struct semaphore {
int value; /* 计数器 */
int waiters; /* 等待的进程数 */
};
void sem_init(struct semaphore *sem, int value) {
sem->value = value;
sem->waiters = 0;
}
int sem_wait(struct semaphore *sem) {
if (sem->value > 0) {
sem->value--;
return 0; /* 成功获取 */
}
sem->waiters++;
return -1; /* 阻塞 */
}
void sem_post(struct semaphore *sem) {
if (sem->waiters > 0) {
sem->waiters--;
} else {
sem->value++;
}
}
printf("=== 信号量 — 同步之锁 ===\n\n");
struct semaphore mutex;
sem_init(&mutex, 1); /* 互斥信号量 */
int shared_counter = 0;
printf("--- 没有信号量保护 ---\n");
/* 两个进程同时修改 */
int a = shared_counter; /* 进程 A 读取 */
int b = shared_counter; /* 进程 B 读取 */
a++; /* 进程 A 修改 */
b++; /* 进程 B 修改 */
shared_counter = a; /* 进程 A 写入 */
shared_counter = b; /* 进程 B 写入 */
printf("shared_counter = %d (期望 2,实际 %d)\n\n",
2, shared_counter);
printf("--- 有信号量保护 ---\n");
shared_counter = 0;
/* 进程 A */
sem_wait(&mutex); /* 获取锁 */
shared_counter++; /* 临界区 */
sem_post(&mutex); /* 释放锁 */
/* 进程 B */
sem_wait(&mutex); /* 获取锁 */
shared_counter++; /* 临界区 */
sem_post(&mutex); /* 释放锁 */
printf("shared_counter = %d (正确)\n\n", shared_counter);
printf("--- 信号量的类型 ---\n");
printf("互斥信号量 (Mutex):\n");
printf(" 值只有 0 和 1\n");
printf(" 保护临界区\n");
printf(" 同一时间只有一个进程能进入\n\n");
printf("计数信号量:\n");
printf(" 值可以是任意非负整数\n");
printf(" 控制并发访问的数量\n");
printf(" 例如:数据库连接池、线程池\n");#include <stdio.h>
/*
* 信号量 (Semaphore) 的作用:
* 控制多个进程对共享资源的访问。
*
* 信号量是一个计数器:
* - sem_wait(): 计数器减 1,如果为 0 则阻塞
* - sem_post(): 计数器加 1,唤醒等待的进程
* - sem_trywait(): 非阻塞版本
*
* 互斥信号量 (Mutex):
* 计数器只有 0 和 1 两个值
* 用于保护临界区(同一时间只有一个进程能进入)
*/
struct semaphore {
int value; /* 计数器 */
int waiters; /* 等待的进程数 */
};
void sem_init(struct semaphore *sem, int value) {
sem->value = value;
sem->waiters = 0;
}
int sem_wait(struct semaphore *sem) {
if (sem->value > 0) {
sem->value--;
return 0; /* 成功获取 */
}
sem->waiters++;
return -1; /* 阻塞 */
}
void sem_post(struct semaphore *sem) {
if (sem->waiters > 0) {
sem->waiters--;
} else {
sem->value++;
}
}
int main() {
printf("=== 信号量 — 同步之锁 ===\n\n");
struct semaphore mutex;
sem_init(&mutex, 1); /* 互斥信号量 */
int shared_counter = 0;
printf("--- 没有信号量保护 ---\n");
/* 两个进程同时修改 */
int a = shared_counter; /* 进程 A 读取 */
int b = shared_counter; /* 进程 B 读取 */
a++; /* 进程 A 修改 */
b++; /* 进程 B 修改 */
shared_counter = a; /* 进程 A 写入 */
shared_counter = b; /* 进程 B 写入 */
printf("shared_counter = %d (期望 2,实际 %d)\n\n",
2, shared_counter);
printf("--- 有信号量保护 ---\n");
shared_counter = 0;
/* 进程 A */
sem_wait(&mutex); /* 获取锁 */
shared_counter++; /* 临界区 */
sem_post(&mutex); /* 释放锁 */
/* 进程 B */
sem_wait(&mutex); /* 获取锁 */
shared_counter++; /* 临界区 */
sem_post(&mutex); /* 释放锁 */
printf("shared_counter = %d (正确)\n\n", shared_counter);
printf("--- 信号量的类型 ---\n");
printf("互斥信号量 (Mutex):\n");
printf(" 值只有 0 和 1\n");
printf(" 保护临界区\n");
printf(" 同一时间只有一个进程能进入\n\n");
printf("计数信号量:\n");
printf(" 值可以是任意非负整数\n");
printf(" 控制并发访问的数量\n");
printf(" 例如:数据库连接池、线程池\n");
return 0;
}"我叫信号量。"那个计数器说,"我的工作是控制谁能在什么时间进入临界区。sem_wait() 把我的计数器减 1——如果已经是 0,进程就得等着。sem_post() 把计数器加 1——唤醒等待的进程。"
"所以你是一个门卫。"
"互斥信号量是门卫——同一时间只放一个人进去。"信号量说,"计数信号量是容量控制器——最多放 N 个人进去。"
共享需要规则。没有规则的共享,就是混乱。
三
林小源在研究 IPC 的过程中,对内核的 IPC 子系统有了整体的认识。
他站在一个十字路口。路口的四个方向分别通向不同的 IPC 机制:管道、共享内存、信号量、消息队列。远处还有两条路——信号和套接字。
"每条路通往不同的目的地。"信号量站在路口,充当路标。"管道适合简单的父子进程通信——单向、字节流、不需要同步。共享内存适合大量数据的频繁通信——零复制,但需要你自己处理同步。信号量适合纯同步——不传数据,只控制节奏。"
"消息队列呢?"
"带类型的消息传递。"信号量说,"你可以给消息标记类型,接收方可以选择性地接收特定类型的消息。比管道灵活,但比管道慢。"
"信号?"
"异步通知。"信号量说,"不传数据,只发通知。、——你已经见过了。"
"套接字?"
"最通用。"信号量说,"支持网络通信,支持不同机器之间的 IPC。但开销也最大。"
林小源看着那六条路,明白了:IPC 不是"一种"技术,而是一个"家族"。每种机制都有自己的适用场景,每种都有自己的代价。
"你怎么选?"林小源问。
"看需求。"信号量说,"简单通信用管道,大量数据用共享内存,纯同步用信号量,结构化消息用消息队列,网络通信用套接字。没有最好的,只有最合适的。"
道藏笔记
内核启示
共享内存是最快的 IPC 方式——零数据复制。
共享内存的实现:
- — 创建共享内存段
- — 映射到进程地址空间
- 多个进程映射同一块物理内存
- 直接读写——不需要内核中转
共享内存的同步问题:
- 多个进程同时读写 → 竞态条件
- 需要信号量、互斥锁等同步机制
信号量(Semaphore):
- 一个计数器,控制并发访问
sem_wait()— 减少计数器,为 0 则阻塞sem_post()— 增加计数器,唤醒等待者- 互斥信号量(Mutex)— 计数器为 1,保护临界区
IPC 机制对比:
- 管道 — 简单、单向、适合流式数据
- 共享内存 — 最快、需要同步、适合大量数据
- 信号量 — 同步工具、不传数据
- 消息队列 — 结构化消息、带类型
- 信号 — 异步通知、不传数据
- 套接字 — 最通用、支持网络
共享内存让进程"零距离"通信——但零距离也意味着零保护。
共享之试
本章讲共享资源同步时,线程进入临界区前等待信号量的调用是什么?