笔记:进程间通信——同步(互斥锁、读写锁、条件变量、信号量)以及Linux中的RCU

1.互斥锁

多个线程的IPC,需要同步,同步有隐式的和显示的:

比如unix提供的管道和FIFO,由内核负责同步,比如read发生在write之前,那么read就会被内核阻塞,这中同步是由内核负责的,用户不会感知。

但如果使用共享区作为生产者和消费者之间的IPC,那么程序员就需要负责同步,这种称为显示同步。


2.读写锁

互斥锁把试图进入临界区的所有其他线程都阻塞住。该临界区通常涉及对由这些线程共享的一个或多个数据的访问或更新。

但我们有时候可以在read某个数据和write某个数据之间做区分,即读写锁(read-write lock),规则如下:

(1)只要没有线程持有读写锁进行写,那么任意数目的线程可以持有该读写锁进行度

(2)仅当没有线程持有读写锁进行读或者写时,才能分配该读写锁用于写

对于那些读操作比写操作频繁的应用,读写锁相比互斥锁具有更好的性能。因为它允许多个读者读数据,具有更高的并发性,同时又能保证写者不受干扰。

读写锁

pthread_rwlock_t

#include 
int pthread_rwlock_rdlock(pthread_rwlock_t *rwptr);//获取一个读出锁,rwptr已经由写着持有,那么阻塞
int pthread_rwlock_wrlock(pthread_rwlock_t *rwptr);//获取一个写入锁,如果已经由读者或写着持有,那么阻塞


3.条件变量

互斥锁,当消费者等待生产者的数据时,需要不断的测试(即spinning),这种反复的检测和轮询会浪费CPU的时间,为了避免这种spining,自旋转lock,改进,使用条件变量。

互斥锁用于上锁,条件变量则用于等待。这两种不同的同步都是需要的。

posix的条件变量为pthread_cond_t

#include

int pthread_cond_wait(pthread_cond_t *cptr, pthread_mutex_t *mptr);

int pthread_cond_signal(pthread_cond_t);这不是Unix中的SIG

条件变量是用来等待而不是用来上锁的。条件变量用来阻塞一个线程,直到某特殊情况发生为止。通常条件变量和互斥锁同时使用。条件的检测是在互斥锁的保护下进行的。如果一个条件为假,一个线程自动阻塞,并释放等待状态改变的互斥锁。如果另一个线程改变了条件,它发信号给关联的条件变量,唤醒一个或多个等待它的线程,重新获得互斥锁,重新评价条件。如果两进程共享可读写的内存,条件变量可以被用来实现这两进程间的线程同步

#include 
#include 

static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

struct node {
int n_number;
struct node *n_next;
} *head = NULL;

/*[thread_func]*/
static void cleanup_handler(void *arg)
{
    printf("Cleanup handler of second thread./n");
    free(arg);
    (void)pthread_mutex_unlock(&mtx);
}
static void *thread_func(void *arg)
{
    struct node *p = NULL;

    pthread_cleanup_push(cleanup_handler, p);
    while (1) {
    pthread_mutex_lock(&mtx);           //这个mutex主要是用来保证pthread_cond_wait的并发性
    while (head == NULL)   {  //这个while要特别说明一下,单个pthread_cond_wait功能很完善,为何这里要有一个while (head == NULL)呢?因为pthread_cond_wait里的线程可能会被意外唤醒,如果这个时候head != NULL,则不是我们想要的情况。这个时候,应该让线程继续进入pthread_cond_wait
        pthread_cond_wait(&cond, &mtx);         // pthread_cond_wait会先解除之前的pthread_mutex_lock锁定的mtx,然后阻塞在等待对列里休眠,直到再次被唤醒(大多数情况下是等待的条件成立而被唤醒,唤醒后,该进程会先锁定先pthread_mutex_lock(&mtx);,再读取资源
                 //用这个流程是比较清楚的/*block-->unlock-->wait() return-->lock*/
    }
        p = head;
        head = head->n_next;
        printf("Got %d from front of queue/n", p->n_number);
        free(p);
        pthread_mutex_unlock(&mtx);             //临界区数据操作完毕,释放互斥锁
    }
    pthread_cleanup_pop(0);
    return 0;
}

int main(void)
{
    pthread_t tid;
    int i;
    struct node *p;
    pthread_create(&tid, NULL, thread_func, NULL);   //子线程会一直等待资源,类似生产者和消费者,但是这里的消费者可以是多个消费者,而不仅仅支持普通的单个消费者,这个模型虽然简单,但是很强大
    /*[tx6-main]*/
    for (i = 0; i < 10; i++) {
        p = malloc(sizeof(struct node));
        p->n_number = i;
        pthread_mutex_lock(&mtx);             //需要操作head这个临界资源,先加锁,
        p->n_next = head;
        head = p;
        pthread_cond_signal(&cond);
        pthread_mutex_unlock(&mtx);           //解锁
        sleep(1);
    }
    printf("thread 1 wanna end the line.So cancel thread 2./n");
    pthread_cancel(tid);             //关于pthread_cancel,有一点额外的说明,它是从外部终止子线程,子线程会在最近的取消点,退出线程,而在我们的代码里,最近的取消点肯定就是pthread_cond_wait()了。关于取消点的信息,有兴趣可以google,这里不多说了
    pthread_join(tid, NULL);
    printf("All done -- exiting/n");
    return 0;
}


4.posix信号量

一个信号上的三种操作

1.创建一个信号量,并赋予初始值

2.等待(wait)一个信号量,即P操作,或者上锁(lock),等待(wait)

3.挂出(post)一个信号量,即V操作,或者解锁(unlock),发送信号(singal)

计算信号量(counting semaphore),计算信号量通常初始化为某个值N,指示可用资源数,

二值信号量可用于互斥目的,就像互斥锁一样

 初始化信号量为1; 
sem_wait(&sem);
临界区;
sem_post(&sem);

初始化互斥锁;
pthread_mutex_lock(&mutex); 
临界区;
pthread_mutex_unlock(&mutex);

sem_wait调用中等待信号量值变为1,即有了可用资源,然后再减1,

sem_post调用将信号量的值加1,然后唤醒阻塞在sem_wait调用中等待该信号量的任何线程

两者的区别:互斥锁必须总是由锁住它的线程解锁,信号量的挂出(sem_post)却不必由执行过它的等待操作的同一线程执行


信号量,互斥锁,条件变量之间的区别

1.互斥锁必须总是由锁住它的线程解锁,信号量的挂出却不必由执行过它的等待操作的同一线程执行。

2.互斥锁要么被锁住,要么被解开(二值状态,二值信号量)

3.信号量有一个与之关联的状态(它的计数值)


Posix提供的信号量分两种:有名(named)信号量和基于内存的(memory-based)的信号量(也称为无名unnamed)信号量。

1.有名信号量

#include 
sem_t *sem_open(const char *name, int oflag, ...)
成功则返回指向信号量的指针,若出现错误则为SEM_FAILED

sem_open返回值指向sem_t数据类型的指针。

#include 
int sem_close(sem_t *sem);
关闭信号量。
但关闭一个信号量并没有从系统中删除:即使当前没有进程打开着某个信号量,它的值仍然保持

#include 
int sem_unlink(const char *name);   
有名信号量使用sem_unlink从系统中删除

每个信号量都有一个引用计数器,记录当前打开次数:其信号量的析构却要等到最后一个sem_close发生为止

sem_wait函数测试所制定信号量的值,如果该值大于0,那就将它减1并立即返回。如果该值等于0,调用线程就被投入睡眠中,直到该值为大于0,

#include 
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);

sem_wait和sem_trywait的差别是:当所指定信号量的值已经是0时,后者并不将调用线程投入睡眠,而是返回一个EAGAIN错误。

#include 
int sem_post(sem_t *sem);
int sem_getvalue(sem_t *sem, int *valp);

sem_post和sem_getvalue

当一个线程使用玩某个信号量时,它应该调用sem_post,本函数把所指定信号的值加1,然后唤醒正在等待该信号量值变为正数的任意线程

sem_getvalue在由valp指向的整数中返回指定信号量的当前值,可以为负数,其绝对值就是等待信号量解锁的线程数。


2.无名信号量,基于内存的信号量

它们由应用程序分配信号量的内存空间(即分配一个sem_t数据类型的内存空间),然后由系统初始化它们。

#include 
int sem_init(sem_t *sem, int shared, unsigned int value);
int sem_destory(sem_t *sem);
sem_init中sem参数必须指向应用程序分配的sem_t变量,如果shared为0,那么待初始化的信号量是在同一个进程的各个线程间共享的,否则该信号量是在进程间共享的。如果是共享的,那么sem必须放在各个进程的共享区。

5.Linux内核中的RCU机制

RCU(Read-Copy Update)是数据同步的一种方式,在当前的Linux内核中发挥着重要的作用。RCU主要针对的数据对象是链表,目的是提高遍历读取数据的效率,为了达到目的使用RCU机制读取数据的时候不对链表进行耗时的加锁操作。这样在同一时间可以有多个线程同时读取该链表,并且允许一个线程对链表进行修改(修改的时候,需要加锁)。(是不是有点类似与读写锁呢

RCU适用于需要频繁的读取数据,而相应修改数据并不多的情景,例如在文件系统中,经常需要查找定位目录,而对目录的修改相对来说并不多,这就是RCU发挥作用的最佳场景。

通过允许在更新的同时读数据,RCU 提高了同步机制的可伸缩性(scalability)。相对于传统的在并发线程间不区分是读者还是写者的简单互斥性锁机制,或者是哪些允许并发读但同时不 允许写的读写锁,RCU 支持同时一个更新线程和多个读线程的并发。RCU 通过保存对象的多个副本来保障读操作的连续性,并保证在预定的读方临界区没有完成之前不会释放这个对象。RCU定义并使用高效、可伸缩的机制来发布并读取 对象的新版本,并延长旧版本们的寿命。这些机制将工作分发到了读和更新路径上,以保证读路径可以极快地运行。在某些场合(非抢占内核),RCU 的读方没有任何性能负担。


RCU的实现?

 在RCU的实现过程中,我们主要解决以下问题:

       1,在读取过程中,另外一个线程删除了一个节点。删除线程可以把这个节点从链表中移除,但它不能直接销毁这个节点,必须等到所有的读取线程读取完成以后,才进行销毁操作。RCU中把这个过程称为宽限期(Grace period)。

       2,在读取过程中,另外一个线程插入了一个新节点,而读线程读到了这个节点,那么需要保证读到的这个节点是完整的。这里涉及到了发布-订阅机制(Publish-Subscribe Mechanism)。

       3, 保证读取链表的完整性。新增或者删除一个节点,不至于导致遍历一个链表从中间断开。但是RCU并不保证一定能读到新增的节点或者不读到要被删除的节点。


 RCU机制是Linux2.6之后提供的一种数据一致性访问的机制,从RCU(read-copy-update)的名称上看,我们就能对他的实现机制有一个大概的了解,在修改数据的时候,首先需要读取数据,然后生成一个副本,对副本进行修改,修改完成之后再将老数据update成新的数据,此所谓RCU。

       在操作系统中,数据一致性访问是一个非常重要的部分,通常我们可以采用锁机制实现数据的一致性访问。例如,semaphore、spinlock机制,在访问共享数据时,首先访问锁资源,在获取锁资源的前提下才能实现数据的访问。这种原理很简单,根本的思想就是在访问临界资源时,首先访问一个全局的变量(锁),通过全局变量的状态来控制线程对临界资源的访问。但是,这种思想是需要硬件支持的,硬件需要配合实现全局变量(锁)的读-修改-写,现代CPU都会提供这样的原子化指令。采用锁机制实现数据访问的一致性存在如下两个问题:

1、  效率问题。锁机制的实现需要对内存的原子化访问,这种访问操作会破坏流水线操作,降低了流水线效率。这是影响性能的一个因素。另外,在采用读写锁机制的情况下,写锁是排他锁,无法实现写锁与读锁的并发操作,在某些应用下回降低性能。

2、  扩展性问题(scalability)。当系统中CPU数量增多的时候,采用锁机制实现数据的同步访问效率偏低。并且随着CPU数量的增多,效率降低,由此可见锁机制实现的数据一致性访问扩展性差。

为了解决上述问题,Linux中引进了RCU机制。该机制在多CPU的平台上比较适用,对于读多写少的应用尤其适用。RCU的思路实际上很简单,下面对其进行描述:

1. 对于读操作,可以直接对共享资源进行访问但是前提是需要CPU支持访存操作的原子化,现代CPU对这一点都做了保证。但是RCU的读操作上下文是不可抢占的(这一点在下面解释),所以读访问共享资源时可以采用read_rcu_lock(),该函数的工作是停止抢占。

2.对于写操作,其需要将原来的老数据作一次备份(copy),然后对备份数据进行修改,修改完毕之后再用新数据更新老数据,更新老数据时采用了rcu_assign_pointer()宏,在该函数中首先屏障一下memory,然后修改老数据。这个操作完成之后,需要进行老数据资源的回收。操作线程向系统注册回收方法,等待回收。采用数据备份的方法可以实现读者与写者之间的并发操作,但是不能解决多个写者之间的同步,所以当存在多个写者时,需要通过锁机制对其进行互斥,也就是在同一时刻只能存在一个写者。

3.RCU机制中存在一个垃圾回收的daemon,当共享资源被update之后,可以采用该daemon实现老数据资源的回收。回收时间点就是在update之前的所有的读者全部退出。由此可见写者在update之后是需要睡眠等待的,需要等待读者完成操作,如果在这个时刻读者被抢占或者睡眠,那么很可能会导致系统死锁。因为此时写者在等待读者,读者被抢占或者睡眠,如果正在运行的线程需要访问读者和写者已经占用的资源,那么死锁的条件就很有可能形成了。

从上述分析来看, RCU 思想是比较简单的,其核心内容紧紧围绕“写时拷贝”,采用 RCU 机制,能够保证在读写操作共享资源时,基本不需要取锁操作,能够在一定程度上提升性能。但是该机制的应用是有条件的,对于读多写少的应用,机制的开销比较小,性能会大幅度提升,但是如果写操作较多时,开销将会增大,性能不一定会有所提升。总体来说, RCU 机制是对 rw_lock 的一种优化。

你可能感兴趣的:(unix环境高级编程学习)