Skip to content

第三十四章:共享之秘

筑基后期

涉及内核源码:

林小源在研究管道之后,发现了一种更快的进程间通信方式:共享内存。

管道的数据需要从用户态复制到内核态(write()),再从内核态复制到另一个进程的用户态(read())。两次复制。但共享内存不同——两个进程直接访问同一块物理内存,零复制。

他走进一间透明的房间。房间的墙壁是玻璃做的,两个进程站在房间两侧,中间是一块发光的物理页。左边的进程往物理页上写字,右边的进程立刻看到了。

"你们不需要复制?"林小源问。

"不需要。"左边的进程说,"这块物理页映射到了我们两个的地址空间里。我的虚拟地址和它的虚拟地址不同,但我们指向同一块物理内存。我写什么,它立刻看到什么。"

"这是最快的通信方式。"右边的进程补充道,"管道需要两次复制——用户态到内核态,内核态到用户态。我们只需要一次映射,之后就是直接读写。"

c
/*
 * 共享内存的原理:
 * 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");

最快的通信,是"不通信"——直接共享。

林小源在研究共享内存的过程中,撞上了并发编程的核心难题。

如果两个进程同时修改共享内存中的同一个变量,会发生什么?

他站在那间透明的房间外面,看到两个进程同时伸手去写那块物理页上的同一个位置。左边的进程写了 6,右边的进程写了 7。最终那个位置的值是 7——但左边的进程不知道自己的修改被覆盖了。

"你们同时写了!"林小源喊道。

两个进程都愣住了。

"我读到的是 5。"左边的进程说。

"我也读到的是 5。"右边的进程说。

"你改成了 6?"

"你改成了 7?"

"最终是 7。"林小源说,"你的修改丢失了。"

两个进程面面相觑。

"这就是竞态条件。"一个低沉的声音从旁边传来。林小源转头,看到一个计数器形状的存在——信号量。它有两个操作:sem_wait()sem_post()

c
/*
 * 信号量 (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");

"我叫信号量。"那个计数器说,"我的工作是控制谁能在什么时间进入临界区。sem_wait() 把我的计数器减 1——如果已经是 0,进程就得等着。sem_post() 把计数器加 1——唤醒等待的进程。"

"所以你是一个门卫。"

"互斥信号量是门卫——同一时间只放一个人进去。"信号量说,"计数信号量是容量控制器——最多放 N 个人进去。"

共享需要规则。没有规则的共享,就是混乱。

林小源在研究 IPC 的过程中,对内核的 IPC 子系统有了整体的认识。

他站在一个十字路口。路口的四个方向分别通向不同的 IPC 机制:管道、共享内存、信号量、消息队列。远处还有两条路——信号和套接字。

"每条路通往不同的目的地。"信号量站在路口,充当路标。"管道适合简单的父子进程通信——单向、字节流、不需要同步。共享内存适合大量数据的频繁通信——零复制,但需要你自己处理同步。信号量适合纯同步——不传数据,只控制节奏。"

"消息队列呢?"

"带类型的消息传递。"信号量说,"你可以给消息标记类型,接收方可以选择性地接收特定类型的消息。比管道灵活,但比管道慢。"

"信号?"

"异步通知。"信号量说,"不传数据,只发通知。——你已经见过了。"

"套接字?"

"最通用。"信号量说,"支持网络通信,支持不同机器之间的 IPC。但开销也最大。"

林小源看着那六条路,明白了:IPC 不是"一种"技术,而是一个"家族"。每种机制都有自己的适用场景,每种都有自己的代价。

"你怎么选?"林小源问。

"看需求。"信号量说,"简单通信用管道,大量数据用共享内存,纯同步用信号量,结构化消息用消息队列,网络通信用套接字。没有最好的,只有最合适的。"


道藏笔记

内核启示

共享内存是最快的 IPC 方式——零数据复制。

共享内存的实现:

  1. — 创建共享内存段
  2. — 映射到进程地址空间
  3. 多个进程映射同一块物理内存
  4. 直接读写——不需要内核中转

共享内存的同步问题:

  • 多个进程同时读写 → 竞态条件
  • 需要信号量、互斥锁等同步机制

信号量(Semaphore):

  • 一个计数器,控制并发访问
  • sem_wait() — 减少计数器,为 0 则阻塞
  • sem_post() — 增加计数器,唤醒等待者
  • 互斥信号量(Mutex)— 计数器为 1,保护临界区

IPC 机制对比:

  • 管道 — 简单、单向、适合流式数据
  • 共享内存 — 最快、需要同步、适合大量数据
  • 信号量 — 同步工具、不传数据
  • 消息队列 — 结构化消息、带类型
  • 信号 — 异步通知、不传数据
  • 套接字 — 最通用、支持网络

共享内存让进程"零距离"通信——但零距离也意味着零保护。


破关试炼

共享之试

本章讲共享资源同步时,线程进入临界区前等待信号量的调用是什么?

答对后才能继续滑动和进入下一章。

以修仙之名,悟内核之道