8. POSIX信号量

本章节将讲述另一种进程/线程间通信的机制——POSIX信号量,为了明确与systemV信号量间的区别, 若非特别说明,本章出现的信号量均为POSIX信号量。

8.1. POSIX信号量基本概念

信号量(Semaphore)是一种实现进程/线程间通信的机制,可以实现进程/线程之间同步或临界资源的互斥访问, 常用于协助一组相互竞争的进程/线程来访问临界资源。在多进程/线程系统中, 各进程/线程之间需要同步或互斥实现临界资源的保护,信号量功能可以为用户提供这方面的支持。

在POSIX标准中,信号量分两种,一种是无名信号量,一种是有名信号量。 无名信号量一般用于进程/线程间同步或互斥,而有名信号量一般用于进程间同步或互斥。 有名信号量和无名信号量的差异在于创建和销毁的形式上,但是其他工作一样,无名信号量则直接保存在内存中, 而有名信号量则要求创建一个文件。

正如其名,无名信号量没有名字,它只能存在于内存中,这就要求使用信号量的进程/线程必须能访问无名信号量所在的这一块内存, 所以无名信号量只能应用在同一进程内的线程之间同步或者互斥。相反,有名信号量可以通过名字访问, 因此可以被任何知道它们名字的进程或者进程/线程使用。单个进程中使用POSIX信号量时,无名信号量更简单, 多个进程间使用POSIX信号量时,有名信号量更简单。

8.1.1. POSIX与System V信号量对比

Linux支持两类信号量,实际开发中POSIX信号量更常用,二者核心差异梳理如下:

  • 接口复杂度:System V信号量依托ftok、semget、semop等接口,操作繁琐、代码冗余;POSIX信号量接口极简,语义清晰,上手更快。

  • 资源管控:System V信号量属于内核持久对象,易产生僵尸IPC资源,排查清理繁琐;POSIX有名信号量依托文件管理,无名信号量无残留,清理更便捷。

  • 跨平台性:POSIX信号量遵循国际标准,Unix、Linux、macOS均支持,可移植性极强;System V信号量是Unix传统IPC,仅类Unix系统支持,跨平台适配性差。

  • 性能开销:POSIX信号量轻量级,上下文切换开销更小;System V信号量内核管控更重,开销略高。

8.2. POSIX信号量应用场景

  • 临界资源互斥:二值信号量(初始值=1)替代互斥锁,管控共享内存、全局变量、硬件外设的独占访问。

  • 执行顺序同步:初始值=0的信号量,实现线程/进程的先后执行,如生产者生产后消费者再消费。

  • 限流控制:初始值=N的信号量,允许最多N个执行流同时访问临界资源,实现并发限流。

  • 亲缘进程通信:无名信号量适配父子进程同步,有名信号量适配多无亲缘进程协同。

8.3. POSIX有名信号量

8.3.1. 核心特性

有名信号量以特殊文件形式存储于/dev/shm目录,文件名固定为sem.信号量名格式,不同进程只要约定相同名称, 即可访问同一信号量,实现跨进程同步互斥;其生命周期独立于进程,进程退出后不会自动销毁,必须手动清理,否则会长期占用系统资源。

8.3.2. 核心API

主要用到的函数:

1
2
3
4
5
6
7
8
#include <semaphore.h>

sem_t *sem_open(const char *name, int oflag, mode_t mode, unsigned int value);
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
int sem_post(sem_t *sem);
int sem_close(sem_t *sem);
int sem_unlink(const char *name);

函数解析如下:

  • sem_open()函数用于打开/创建一个有名信号量,它的参数说明如下:

    • name:打开或者创建信号量的名字。

    • oflag:当指定的文件不存在时,可以指定O_CREATE或者O_EXEL进行创建操作, 如果指定为0,后两个参数可省略,否则后面两个参数需要带上。

    • mode:数字表示的文件读写权限,如果信号量已经存在,本参数会被忽略。

    • value:信号量初始的值,这个参数只有在新创建的时候才需要设置,如果信号量已经存在,本参数会被忽略。

    • 返回值:返回值是一个sem_t类型的指针,它指向已经创建/打开的信号量, 后续的函数都通过改信号量指针去访问对应的信号量。

  • sem_wait()函数是等待(获取)信号量,如果信号量的值大于0,将信号量的值减1,立即返回。如果信号量的值为0, 则进程/线程阻塞。相当于P操作。成功返回0,失败返回-1。

  • sem_trywait()函数也是等待信号量,如果指定信号量的计数器为0,那么直接返回EAGAIN错误,而不是阻塞等待。

  • sem_post()函数是释放信号量,让信号量的值加1,相当于V操作。成功返回0,失败返回-1。

  • sem_close()函数用于关闭一个信号量,这表示当前进程/线程取消对信号量的使用,它的作用仅在当前进程/线程, 其他进程/线程依然可以使用该信号量,同时当进程结束的时候,无论是正常退出还是信号中断退出的进程, 内核都会主动调用该函数去关闭进程使用的信号量,即使从此以后都没有其他进程/线程再使用这个信号量了, 内核也会维持这个信号量。

  • sem_unlink()函数就是主动删除一个信号量,直接删除指定名字的信号量文件。

8.4. POSIX无名信号量

8.4.1. 核心特性

无名信号量无名称、无实体文件,直接存储于进程内存空间,仅支持同一进程内线程或有亲缘关系的进程(如父子进程)共享, Linux暂未完全支持非亲缘进程共享无名信号量;其生命周期随内存释放,无需文件管理,资源清理更便捷。

8.4.2. 核心API

主要用到的函数:

1
2
3
4
5
6
7
#include <semaphore.h>

int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_destroy(sem_t *sem);
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
int sem_post(sem_t *sem);

函数解析如下:

  • sem_init():初始化信号量。

    • 其中sem是要初始化的信号量,不要对已初始化的信号量再做sem_init操作,会发生不可预知的问题。

    • pshared表示此信号量是在进程间共享还是线程间共享,由于目前Linux 还没有实现进程间共享无名信号量, 所以这个值只能够取0,表示这个信号量是当前进程的局部信号量。

    • value是信号量的初始值。

    • 返回值:成功返回0,失败返回-1。

  • sem_destroy():销毁信号量,其中sem是要销毁的信号量。只有用sem_init初始化的信号量才能用sem_destroy()函数销毁。 成功返回0,失败返回-1。

  • sem_wait()、sem_trywait()、sem_post()等函数与有名信号量的使用是一样的。

8.5. POSIX信号量示例

8.5.1. 有名信号量示例

首先来分析有名信号量的示例代码:

POSIX有名信号量(base_code/system_programing/posix_sem1/sources/posix_sem.c文件)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#include <unistd.h>
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <fcntl.h>
#include <sys/wait.h>

int main(int argc, char **argv)
{
    int pid;
    sem_t *sem;
    const char sem_name[] = "my_sem_test";

    pid = fork();

    if (pid < 0) {
        printf("error in the fork!\n");
    }
    /* 子进程 */
    else if (pid == 0) {
        /*创建/打开一个初始值为1的信号量*/
        sem = sem_open(sem_name, O_CREAT, 0644, 1);

        if (sem == SEM_FAILED) {
            printf("unable to create semaphore...\n");

            sem_unlink(sem_name);

            exit(-1);
        }
        /*获取信号量*/
        sem_wait(sem);

        for (int i = 0; i < 3; ++i) {

            printf("child process run: %d\n", i);
            /*睡眠释放CPU占用*/
            sleep(1);
        }

    /*释放信号量*/
    sem_post(sem);

    }
    /* 父进程 */
    else {

        /*创建/打开一个初始值为1的信号量*/
        sem = sem_open(sem_name, O_CREAT, 0644, 1);

        if (sem == SEM_FAILED) {
            printf("unable to create semaphore...\n");

            sem_unlink(sem_name);

            exit(-1);
        }
        /*申请信号量*/
        sem_wait(sem);

        for (int i = 0; i < 3; ++i) {

            printf("parent process run: %d\n", i);
            /*睡眠释放CPU占用*/
            sleep(1);
        }

        /*释放信号量*/
        sem_post(sem);
        /*等待子进程结束*/
        wait(NULL);

        /*关闭信号量*/
        sem_close(sem);
        /*删除信号量*/
        sem_unlink(sem_name);
    }

    return 0;
}

本代码示例的分析如下:

  • 第23~47行,前面通过fork创建了子进程,这部分是子进程的代码。

    • 第25行,通过sem_open()函数打开或者创建了一个信号量,信号量的初始值为1。

    • 第35行,调用sem_wait()尝试获取信号量,若信号量值为0,代码将阻塞在此处等待。

    • 第41行,这是在for循环里的一个小睡眠,循环里每打印一句之后都释放CPU一段时间。以便其它进程运行。

    • 第45行,循环执行完毕,调用sem_post()释放信号量。

  • 第49~80行,这部分是父进程的代码。

    • 第52~72行,它与子进程的内容完全一致。都是打开、获取信号量后循环打印,然后释放信号量。

    • 第74~79行,父进程内等待子进程结束后调用sem_close()和sem_unlink()关闭和释放信号量。

本代码两个进程for循环的sleep()是特意加进去模拟释放CPU操作的,进程A释放CPU后,按通常情况来说, 其它进程B会获得CPU而执行代码。但由于本示例的父子进程打印操作时都需要等待同一个信号量,所以进程A虽然睡眠, 但由于还没有释放CPU,进程B由于得不到信号量,并不会执行。

因此,我们可以推算得到这样的结果:

  • 示例代码由于信号量的控制,运行后得到的结果是:进程A连续打印0,1,2三条语句, 而进程B在A释放信号量后,B连续打印0,1,2三条语句。

  • 假如注释掉示例代码所有跟信号量相关的操作(保留for循环里的sleep),那么由于sleep的存在, 运行后得到的结果是:进程A打印0后进入睡眠释放CPU,进程B打印0后进入睡眠释放CPU;进程A打印1、进程B打印1… 即这两个进程轮流执行,轮流打印。

8.5.1.1. 实验操作

本实验的代码存储在 base_code/system_programing/posix_sem1 目录中,编译及运行过程如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 以下操作在 base_code/system_programing/posix_sem1 代码目录进行
make

# 运行
./build/posix_sem1_demo

# 以下是运行的输出
parent process run: 0
parent process run: 1
parent process run: 2
child process run: 0
child process run: 1
child process run: 2

可以看到,两个进程是分别连续打印的。感兴趣的话可以尝试把父、子进程的等待信号量操作sem_wait都注释掉,观看实验现象。

注意:由于fork()后先执行父进程还是子进程是说不定的,只要能区分出是连续打印还是轮流打印即可看出信号量在本示例中的作用。

在代码的运行过程中,如果打开一个新的终端,并且输入以下命令:

1
2
3
ls -l /dev/shm

-rw-r--r--  1 root  root    32 2月  14 13:31 sem.my_sem_test

那么可以发现在 /dev/shm 目录下存在一个 sem.my_sem_test 文件, 这就是我们实验中创建的一个信号量,当进程运行完毕,这个信号量将会被删除, 使用sudo权限调用rm命令也可以手动删除该信号量文件。

8.5.2. 无名信号量示例

下面的例子是用无名信号量同步机制实现3个线程之间的有序执行示例:

POSIX无名信号量(base_code/system_programing/posix_sem/sources/posix_sem.c文件)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>

#define THREAD_NUMBER 3 /* 线程数 */
#define REPEAT_NUMBER 4 /* 每个线程中的小任务数 */

sem_t sem[THREAD_NUMBER];

/*线程函数*/
void *thread_func(void *arg)
{
    int num = (unsigned long long)arg;
    int delay_time = 0;
    int count = 0;

    /* 等待信号量,进行 P 操作 */
    sem_wait(&sem[num]);

    printf("Thread %d is starting\n", num);
    for (count = 0; count < REPEAT_NUMBER; count++)
    {
        printf("\tThread %d: job %d \n",num, count);
        sleep(1);
    }

    printf("Thread %d finished\n", num);
    /*退出线程*/
    pthread_exit(NULL);
}



int main(void)
{
    pthread_t thread[THREAD_NUMBER];
    int i = 0, res;
    void * thread_ret;

    /*创建三个线程,三个信号量*/
    for (i = 0; i < THREAD_NUMBER; i++)
    {
        /*创建信号量,初始信号量值为0*/
        sem_init(&sem[i], 0, 0);
        /*创建线程*/
        res = pthread_create(&thread[i], NULL, thread_func, (void*)(unsigned long long)i);

        if (res != 0)
        {
            printf("Create thread %d failed\n", i);
            exit(res);
        }
    }

    printf("Create treads success\n Waiting for threads to finish...\n");

    /*按顺序释放信号量 V操作*/
    for (i = 0; i<THREAD_NUMBER ; i++)
    {
        /* 进行 V 操作 */
        sem_post(&sem[i]);
        /*等待线程执行完毕*/
        res = pthread_join(thread[i], &thread_ret);
        if (!res)
        {
            printf("Thread %d joined\n", i);
        }
        else
        {
            printf("Thread %d join failed\n", i);
        }

    }

    for (i = 0; i < THREAD_NUMBER; i++)
    {
        /* 删除信号量 */
        sem_destroy(&sem[i]);
    }

    return 0;
}

本代码说明如下,直接从main函数按流程分析:

  • 第44、45行,在for循环内创建了三个信号量存储在数组sem中,创建三个线程, 线程要调用的函数均为thread_func,并且通过变量i传入了线程序号。

  • 第13行,三个线程执行的都是这同样的函数,代码的思路与上一小节有名信号量的示例类似。

    • 第20行,线程先不执行,直接调用sem_wai()等待信号量sem[num],此处num即创建线程传入的序号参数i。 即每个线程均等待与自己序号相同的信号量。

    • 第23~27行,得到信号量后在for循环里打印信息并睡眠,释放CPU。

    • 第31行,退出本线程。

  • 第58~73行,此时各个子线程已经创建完成,均在等待信号量,这时在原线程里的for循环里调用sem_post()按顺序释放信号量, 并且调用pthread_join()等待该线程执行完毕再释放下一个信号量。

  • 第78行,调用sem_destroy释放各个信号量。

可以推算到如下现象: 在原线程的控制下,它所创建的线程ABC按照释放信号量的次序执行,而且即使上一线程有释放CPU的操作,下一个线程也不会得到CPU的光顾, 因为它未等到自己的信号量。从而在控制下不会出现ACBBAC之类的乱序操作。

8.5.2.1. 实验操作

本实验的代码存储在 base_code/system_programing/posix_sem 目录中,编译及运行过程如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 以下操作在 base_code/system_programing/posix_sem 代码目录进行
make

# 运行
./build_x86/posix_sem_demo

# 以下是运行的输出
Create treads success
Waiting for threads to finish...
Thread 0 is starting
        Thread 0: job 0
        Thread 0: job 1
        Thread 0: job 2
        Thread 0: job 3
Thread 0 finished
Thread 0 joined
Thread 1 is starting
        Thread 1: job 0
        Thread 1: job 1
        Thread 1: job 2
        Thread 1: job 3
Thread 1 finished
Thread 1 joined
Thread 2 is starting
        Thread 2: job 0
        Thread 2: job 1
        Thread 2: job 2
        Thread 2: job 3
Thread 2 finished
Thread 2 joined

可以看到,三个进程是分别连续打印,而且是按信号量释放的次序执行的。 感兴趣的话可以尝试把线程函数的等待信号量操作sem_wait注释掉,观看实验现象。

注意:无名信号量不会在系统中创建文件,所以无法像有名信号量那样通过 ls -l /dev/shm 命令查看到。