一、为什么需要同步?

1. 临界资源问题

在操作系统中,临界资源是指一次仅允许一个进程(或线程)访问的资源。例如:共享内存、全局变量、文件、打印机等。当多个进程同时读写临界资源时,结果可能依赖于它们的执行顺序,这就是竞态条件(Race Condition)

2. 数据不一致示例

考虑一个简单的共享计数器,两个进程同时对其加 1:

// 假设 counter 初始为 0
counter++;   // 实际对应三条汇编指令:
// 1. load  counter 到寄存器
// 2. add   1 到寄存器
// 3. store 寄存器回 counter

如果两个进程同时执行,可能出现如下交错:

  • 进程 A 加载 counter (0)
  • 进程 B 加载 counter (0)
  • 进程 A 加 1 (1)
  • 进程 B 加 1 (1)
  • 进程 A 存储 (counter=1)
  • 进程 B 存储 (counter=1)

最终结果应该是 2,却得到了 1。这就是典型的竞态条件,导致数据不一致。

结论:必须提供同步机制,确保对临界资源的访问是互斥的。


二、基本概念

概念 定义
临界区(Critical Section) 访问共享资源的代码段,同一时刻最多允许一个执行流进入。
互斥(Mutual Exclusion) 保证一次只有一个进程进入临界区,其他进程必须等待。
原子操作(Atomic Operation) 不可被中断的操作,要么全部完成,要么全部不执行。

同步机制的目标就是:实现互斥,避免竞态


三、信号量原理

信号量(Semaphore)是荷兰计算机科学家 Dijkstra 在 1965 年提出的同步工具,本质上是一个计数器,用来控制对共享资源的访问。

1. 核心组成

  • 计数器(semaphore value):表示当前可用的资源数量。
    • 正值:有这么多资源可用。
    • 零:没有资源可用,申请者必须等待。
    • 负值:绝对值表示等待队列中的进程数(取决于具体实现)。
  • 等待队列:当进程无法获得资源时,会被挂起并加入队列。

2. 两个原子操作

  • P 操作(wait / down):申请资源。
    • 如果计数器 > 0,则将其减 1,进程继续执行。
    • 如果计数器 == 0,则进程被阻塞,加入等待队列。
  • V 操作(signal / up):释放资源。
    • 将计数器加 1。
    • 如果有进程正在等待该信号量,则唤醒其中一个。

注意:P 和 V 操作必须是原子的,通常由硬件指令或操作系统内核保证。

3. 二进制信号量(互斥锁)

当信号量计数器的初始值设为 1 时,它就是一个二进制信号量,用于实现互斥:

  • P 操作相当于 lock(),V 操作相当于 unlock()
  • 同一时刻只有一个进程能成功执行 P 操作,其他进程必须等待。

四、System V 信号量 API

System V 信号量是 Unix 系统中经典的信号量实现,支持信号量集(多个信号量原子操作)。其核心函数如下:

1. semget —— 创建或获取信号量集

#include <sys/sem.h>

int semget(key_t key, int nsems, int semflg);
  • key:IPC 键值,通常由 ftok() 生成。
  • nsems:信号量集中包含的信号量个数(至少为 1)。
  • semflg:权限标志(如 0666)与 IPC_CREATIPC_EXCL 的组合。
  • 返回值:成功返回信号量集标识符 semid,失败返回 -1。

2. semop —— 执行信号量操作

int semop(int semid, struct sembuf *sops, size_t nsops);
  • semid:由 semget 返回的标识符。
  • sops:指向 struct sembuf 数组,每个结构体描述一个操作。
  • nsops:数组中元素个数(支持一次执行多个操作,原子性)。

struct sembuf 定义如下:

struct sembuf {
    unsigned short sem_num;  // 信号量在集合中的索引
    short          sem_op;   // 操作数(负数 = P,正数 = V)
    short          sem_flg;  // 标志,如 IPC_NOWAIT, SEM_UNDO
};
  • sem_op = -1:P 操作(申请资源)。
  • sem_op = +1:V 操作(释放资源)。

3. semctl —— 信号量控制

int semctl(int semid, int semnum, int cmd, ...);

常用命令:

  • IPC_RMID:删除信号量集。
  • SETVAL:设置某个信号量的值(通常用于初始化)。
  • GETVAL:获取某个信号量的当前值。
  • IPC_STAT:获取状态信息。

五、示例:用信号量保护共享内存

下面演示一个完整示例:两个进程通过共享内存共享一个结构体,并用二进制信号量保证互斥访问。

1. 共享数据结构

// shared_data.h
#ifndef SHARED_DATA_H
#define SHARED_DATA_H

#define SHM_SIZE 4096
#define SEM_KEY 0x1234
#define SHM_KEY 0x5678

struct shared_data {
    int counter;
    char buffer[100];
};

#endif

2. 初始化(由其中一个进程执行)

// init.c
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/shm.h>
#include "shared_data.h"

int main() {
    // 创建信号量集(1个信号量)
    int semid = semget(SEM_KEY, 1, IPC_CREAT | IPC_EXCL | 0666);
    if (semid == -1) {
        perror("semget");
        return 1;
    }
    // 初始化信号量值为 1(二进制信号量)
    semctl(semid, 0, SETVAL, 1);

    // 创建共享内存
    int shmid = shmget(SHM_KEY, SHM_SIZE, IPC_CREAT | IPC_EXCL | 0666);
    if (shmid == -1) {
        perror("shmget");
        return 1;
    }
    // 附加并初始化共享数据
    struct shared_data *data = shmat(shmid, NULL, 0);
    data->counter = 0;
    sprintf(data->buffer, "initial");
    shmdt(data);

    printf("Initialization done. semid=%d, shmid=%d\n", semid, shmid);
    return 0;
}

3. 生产者进程(写入共享内存)

// producer.c
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/shm.h>
#include <string.h>
#include <unistd.h>
#include "shared_data.h"

int main() {
    // 获取已有的信号量集
    int semid = semget(SEM_KEY, 1, 0666);
    if (semid == -1) {
        perror("semget");
        return 1;
    }

    // 获取共享内存
    int shmid = shmget(SHM_KEY, SHM_SIZE, 0666);
    if (shmid == -1) {
        perror("shmget");
        return 1;
    }
    struct shared_data *data = shmat(shmid, NULL, 0);

    // P 操作:申请资源
    struct sembuf p_op = {0, -1, 0};
    semop(semid, &p_op, 1);

    // 进入临界区,修改共享数据
    data->counter++;
    sprintf(data->buffer, "Updated by producer, count=%d", data->counter);
    printf("Producer: %s\n", data->buffer);

    // V 操作:释放资源
    struct sembuf v_op = {0, 1, 0};
    semop(semid, &v_op, 1);

    shmdt(data);
    return 0;
}

4. 消费者进程(读取共享内存)

// consumer.c
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/shm.h>
#include "shared_data.h"

int main() {
    int semid = semget(SEM_KEY, 1, 0666);
    if (semid == -1) {
        perror("semget");
        return 1;
    }

    int shmid = shmget(SHM_KEY, SHM_SIZE, 0666);
    if (shmid == -1) {
        perror("shmget");
        return 1;
    }
    struct shared_data *data = shmat(shmid, NULL, 0);

    // P 操作:申请资源
    struct sembuf p_op = {0, -1, 0};
    semop(semid, &p_op, 1);

    // 临界区:读取共享数据
    printf("Consumer: counter=%d, buffer=%s\n", data->counter, data->buffer);

    // V 操作:释放资源
    struct sembuf v_op = {0, 1, 0};
    semop(semid, &v_op, 1);

    shmdt(data);
    return 0;
}

5. 清理(删除 IPC 对象)

// cleanup.c
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/shm.h>
#include "shared_data.h"

int main() {
    int semid = semget(SEM_KEY, 1, 0666);
    int shmid = shmget(SHM_KEY, SHM_SIZE, 0666);
    if (semid != -1) semctl(semid, 0, IPC_RMID);
    if (shmid != -1) shmctl(shmid, IPC_RMID, NULL);
    printf("IPC objects removed.\n");
    return 0;
}

6. 运行示例

$ ./init
Initialization done. semid=32768, shmid=4096

$ ./producer
Producer: Updated by producer, count=1

$ ./consumer
Consumer: counter=1, buffer=Updated by producer, count=1

$ ./cleanup
IPC objects removed.

如果去掉信号量保护,同时运行多个 producer 和 consumer,就会出现数据错乱。有了信号量,无论启动多少个进程,临界区的代码总是互斥执行的。


六、总结

知识点 要点
为什么需要同步 避免竞态条件,保证共享数据的一致性。
临界区与互斥 访问共享资源的代码段需要互斥保护。
信号量原理 计数器 + P/V 操作,可实现资源计数与互斥。
二进制信号量 初值为 1 的信号量,等同于互斥锁。
System V API semget, semop, semctl
保护共享内存 使用信号量将共享内存的读写变成临界区。
Logo

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

更多推荐