并发 - 信号量

并发 - 信号量

信号量在CSAPP中已经看过了,本质就是一个整数变量, 只要操作这个整数变量, 就必须要使用P操作和V操作. 信号量 信号量作为锁 信号量作为条件变量 信号量与锁配合使用实现生产者-消费者模型 读者与写者锁 如何实现信号量 信号量 信号量由Dijkstra及其同事发明, 综合了条件变量和锁的特性,

信号量在CSAPP中已经看过了,本质就是一个整数变量, 只要操作这个整数变量, 就必须要使用P操作和V操作.
  1. 信号量
  2. 信号量作为锁
  3. 信号量作为条件变量
  4. 信号量与锁配合使用实现生产者-消费者模型
  5. 读者与写者锁
  6. 如何实现信号量

信号量

信号量由Dijkstra及其同事发明, 综合了条件变量和锁的特性, 可以作为与同步有关的所有工作的唯一原语, 也就是仅仅使用信号量, 就可以完成所有并发的编程工作, 即完成锁或者条件变量的功能. 信号量就不是一个简单的整数了, 而是一个类似于struct结构体或者说是一个对象, 其中有整数变量, 也有类似于线程队列的数据结构, 用来唤醒. 可以说信号量综合了普通锁与条件变量的特点. unix 的信号量库是<semaphore.h>, 信号量的数据类型是sem_t , 初始化方法是sem_init(%s, n1, n2). 初始方法的%s就是信号量的地址, 第二个参数始终为0, 表示这个信号量仅仅在当前进程中使用, 如果跨进程使用, 就不能设置为0, 这是更高级的用法了. 这里仅仅看同一个进程中的信号量. 有了之前CSAPP的知识, 一看到信号量就会知道P操作和V操作, P操作就是sem_wait(sem_t *s);, V操作是sem_post(sem_t *s). 从技术上讲, P操作和V操作都是原子操作, P操作把信号量中的整数减1, 然后检测如果为负数, 自己就睡眠. V操作把信号量中的整数加1, 然后唤醒等待在这个信号量上的线程(如果有的话). 所以可以发现, 信号量对象中似乎有一个整数变量, 一个条件变量, 此外肯定还有一个控制读写信号量的互斥锁对象.

信号量作为锁

锁的核心就是互斥, 既然是互斥, 信号量就只能在0-1之间变换, 不允许多个线程同时进入临界区. 这里要把信号量的初始值设置成为1. 取最极端的情况: 第一个线程执行P操作的时候, 信号量就成为0, 然后进入临界区. 这时候第二个线程也执行P操作, 信号量会变成-1, 但是就会睡眠. 在第一个线程执行V操作的时候, 会将信号量重新设置为0, 然后退出唤醒第二个线程, 第二个线程检查信号量已经是0, 不阻塞, 然后继续执行, 执行完之后将信号量恢复为1. 将信号量作为锁的时候, 只需要用P操作和V操作环绕着临界区即可. 所以将信号量设置为1的时候, P就相当于lock 而V相当于unlock 只在0-1之间变换的信号量就是二元信号量, 二元信号量只允许一个线程进入临界区, 所以就起到了锁的作用. 当然, 如果只是想使用锁, 之前单独的锁可能效率更高. 但信号量更加通用.

信号量作为条件变量

因为P操作中会检测值, 而V操作中会唤醒变量, 所以信号量还可以作为条件变量来使用. 在之前使用一个单独的整数以及一个条件变量来实现线程互相等待的操作, 就可以用信号量来略微简化:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <semaphore.h>
#include <pthread.h>

sem_t sem;

void * child(void * arg){
    printf("This is child \n");
    sem_post(&sem);
    return NULL;

}

int main(void){
    sem_init(&sem, 0, 0);

    printf("child thread begins\n");

    pthread_t c;

    pthread_create(&c, NULL, child, NULL);

    sem_wait(&sem);

    printf("child thread ends \n");

    return 0;

}
作为条件变量的时候, 要将其初始化为0. 这是因为初始的时候, 如果父线程执行到P操作但是子线程只是创建还没有执行, 一定要保证父线程能够睡眠. 所以P操作将其减1, 减去之后为负数, 必定会睡眠. 如果子线程先执行完, 由于子线程只执行一个V操作, 执行完以后, 父线程就能够通过P操作继续执行. 这样就保证了只要子线程不执行完毕, 父线程就睡眠. 这里还可以扩展一下, 如果有父线程需要等待多个子线程完成工作, 可不能直接把信号量初始化成0或者N, 因为一个子线程完成工作并唤醒后, 可能唤醒父线程, 然后直接结束. 有几个线程, 就可以用几个信号量作为条件变量即可. 比如创建两个子线程, 父线程等待两个子线程:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <semaphore.h>
#include <pthread.h>

sem_t sem;
sem_t sem2;


void * child(void * arg){
    printf("This is child \n");
    sem_post(&sem);
    return NULL;
}


void * child2(void * arg){
    printf("This is child2 \n");
    sem_post(&sem2);
    return NULL;

}

int main(void){
    sem_init(&sem, 0, 0);
    sem_init(&sem2, 0, 0);
    printf("child thread begins\n");
    pthread_t c;
    pthread_t c2;
    pthread_create(&c, NULL, child, NULL);
    pthread_create(&c2, NULL, child2, NULL);

    sem_wait(&sem);
    sem_wait(&sem2);

    printf("child thread ends \n");
    return 0;
}
这里分别等待两个信号量即可, 主线程等待哪一个信号量的顺序没有关系, 因为各个线程的信号量彼此独立. 不管父线程在哪个信号量上睡眠, 都可以保证等待所有的子线程.

信号量与锁配合使用实现生产者-消费者模型

将信号量设置为N的时候, 其实际效果就是同时允许N个线程进入一段区域, 在这段区域中再加锁, 就实现了一个有界缓冲区. 有界缓冲区实际上就是生产者消费者模型的正式名称. 这里的代码就不再放了, 这里写过了. 核心思想是, 用两个信号量一个代表空的槽位数量, 用于控制消费者, 一个代表有数据的槽位数量, 用于控制生产者. 生产者和消费者都先P操作控制自己的信号量, 表示有一个生产者或者消费者进入了临界区 之后获取共享缓冲区的锁, 读或者写完成之后, V操作对方的信号量, 表示可读或者可生产的数据增加了, 同时唤醒对方类型的线程. 这样共享缓冲区无论是全满还是全空, 都会让另外一种线程开始执行,不会锁掉. 这个套路只要记住就行了, 这是多线程程序的常用模式之一. 类似于事件队列的方式, 都基于这个模式.

读者与写者锁

这个也是老生常谈了, 就是看谁优先. 读写缓冲区肯定是需要一个互斥锁的. 此外还需要一个东西来控制读者或者写者的数量. 哪个优先, 则哪个类型的线程就可以释放对方的锁, 让对方负责进行. 读写模型通常用于读者比较多, 而写者较少的场景, 如果读写差不多, 实际上可以采用类似于生产者与消费者模型, 如果写者较多, 则一般还是读优先, 否则可能会出现长时间无法读取的情况.

如何实现信号量

之前铺垫了很久, 信号量中会有一个整数, 一个条件变量. 再加上一个互斥锁用来保护更新信号量, 就组成一个信号量对象. 如下:
type struct mysem_t {
    int value;
    pthread_cont_t cond;
    pthread_mutex_t lock;
} mysem_t;
其中的value就是初始化其要设置的值, cond实际上带有队列, lock则用来保护自己. 一个一个来实现函数, 首先是初始化, 这个比较容易, 因为只执行一次, 不用考虑线程安全:
void mysem_init(struct mysem_t *mysem, int value){
    mysem->value = value;
//    调用另外的初始化方法, 初始化条件变量和互斥锁
    cond_init(mysem->cond);
    mutex_init(mysem->lock);
}
这个value根据之前的作为锁, 作为条件变量, 共享缓冲区等形式, 可以设置为不同的值. 然后是P操作, 先把value减少1, 然后检测如果value<0, 就睡眠. 可以编写如下:
void P(struct mysem_t *mysem){

    //获取互斥锁, 为了保证更新信号量的安全
    lock(mysem->lock);

    //将信号量的value-1
    mysem->value = mysem->value - 1;

    //判断value ,小于0就休眠
    while (mysem->value < 0) {
        wait(&mysem->cond, &mysem->lock);
    }

    //释放互斥锁
    unlock(mysem->lock);
}
P操作先用互斥量保护自己, 然后将信号量中的整数减少1, 之后检测这个value, 如果小于0就休眠, 休眠的时候使用条件变量, 释放互斥锁, 让其他线程有机会进行P或者V操作. V操作也可以写出了:
void V(struct mysem_t *mysem){
    //获取互斥锁, 为了保证更新信号量的安全
    lock(mysem->lock);

    //给信号量的value+1
    mysem->value = mysem->value + 1;

    //唤醒条件变量上睡眠的线程
    signal(&mysem->cond);

    //释放互斥锁
    unlock(mysem->lock);
}
以后争取就多用信号量写一些东西吧.
LICENSED UNDER CC BY-NC-SA 4.0
Comment