多线程中的同步与锁

时间:2021-09-14 13:04:41

多线程中的同步与锁

概念

前面已经有介绍过线程的概念,所以我们知道,当两个线程同时读写一个内存区域的时候结果可能是不确定的.我们假设写操作需要两个存储器访问周期,
而读操作只需要一个访问周期.在写操作执行了一个访问周期后读操作开始执行,那么得到的结果可能并不是我们想要的.
在这个需求下,我们就需要了解锁以及同步的知识以更好的开发高性能的程序.

互斥量(mutex)

  • 概念: 面对上面的需求我们可以通过互斥接口来保护数据,确保同一时间只有一个线程访问数据.mutex本质上来说是一把锁,我们在访问变量前进行
    加锁,如果此时有任何线程试图对相同的mutex执行加锁操作都会被阻塞,直到完成操作后我们对mutex解锁.此时因为锁被阻塞的线程会被唤醒.
  • 数据类型:互斥变量是用pthread_mutex_t数据类型表示的,下面是初始化以及销毁互斥变量的函数原型

    #include<pthread.h>  
    int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
    //参数attr设置为NULL则为默认属性
    int pthread_mutex_destroy(pthread_mutex_t *mutex);
    //两个函数若成功则返回0,否则返回错误编号
  • 互斥量操作函数原型:

    #include<pthread.h>  
    int pthread_mutex_lock(pthread_t *mutex)

    int pthread_mutex_trylock(pthread_t *mutex)

    int pthread_mutex_unlock(pthread_t *mutex)
    //所有函数若成功则返回0,否则返回错误编号
    • lock函数:对mutex变量进行加锁操作,如果此时已有进程对此mutex加锁则阻塞直到mutex被解锁
    • trylock函数:如果不希望线程被阻塞,可以调用trylock函数尝试对mutex进行加锁操作.如果此时mutex已加锁,则返回EBUSY表示失败.
    • unlock函数:对mutex变量进行解锁操作.

超时互斥量

  • 假如我们希望访问一个受到保护的变量,但是又希望对这个访问做出一个限制:如果等待5s仍旧无法访问我们就放弃这次访问.此时我们就需要用到
    这个超时互斥量接口.
  • 函数原型:

    #include<time.h>
    #include<pthread.h>

    int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex,
    const struct timespec *restrict tsptr);
    //成功返回0,失败则返回错误编号.
  • 在超时到来前mutex被解锁,则函数行为与lock一致.如果超时则返回ETIMEDOUT.

读写锁

  • 假如我们需要保护的变量被修改的次数远远小于被读取的次数,此时我们再使用mutex就会对性能造成一些浪费.因为在大量的读操作中并不会造成
    乱序问题.在这种情况下我们就可以利用读写锁来减少加锁造成的性能损失.

  • 读写锁通过结构体pthread_rwlock_t表示,不同于mutex,读写锁在使用之前必须进行初始化,在释放底层内存前必须销毁.

    #include<pthread.h>

    int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
    const pthread_rwlockattr_t *restrict attr);

    int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
    //两个函数成功则返回0,否则返回错误编号.
  • 函数原型:

    #include<pthread.h>

    int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);

    int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

    int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
    //所有函数成功则返回0,否则返回错误编号.
  • pthread_rwlock_rdlock:读锁,允许n(根据实现决定)个读锁同时锁定.
  • pthread_rwlock_wrlock:写锁,当读锁存在时加写锁会造成进程阻塞.同时为了避免出现饥饿的情况,写锁的状态会阻塞住后面的读锁.
    也就是说,即使写锁是被阻塞的,同样也会阻止其他线程再添加读锁.
  • pthread_rwlock_unlock:不论以哪种形式加锁都可以通过unlock解锁.如果但从函数表现来看,应该是锁内部维护了状态以及计数器,如果是
    读锁则将锁数量减1,如果是写锁则切换锁状态(Go语言的实现方法,我并没有查过C语言的实现源码)

  • 读写锁原语:

    #include<pthread.h>

    int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);

    int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
    //两个函数成功则返回0,否则返回错误编号.

    行为等同于mutex的锁原语.

条件变量

  • 当我们的需求不仅仅是锁住某个临界区,并且还需要判断某些条件是否成立,这个时候条件变量是比mutex更好的选择.

  • 为什么要用条件变量:
    我们来假设这样一个场景,四个线程读取缓冲区,两个线程写入缓冲区.假如此时缓冲区为空,并且写入线程阻塞等待数据,这种情况下四个读取线程会做什么呢?它们会不停的循环进行加锁-判断缓冲区内容-缓冲区为空-解锁.
    这样的频繁加锁解锁的操作很大程度上浪费了CPU资源,所以此时我们需要引入条件变量来帮助我们解决这一问题.

  • 初始化:

    #include<pthread.h>

    int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);

    int pthread_cond_destroy(pthread_cond_t *restrict cond);

    //成功则返回0,失败则返回错误代码
    • 使用条件变量前必须进行初始化.
  • wait:

    #include<pthread.h>

    int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);

    int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex,
    const struct timespec *restrict tsptr);

    //成功则返回0,失败则返回错误代码
    • wait利用mutex以及条件变量对线程进行阻塞,调用者将锁住的互斥量传递给函数,函数将线程放在等待条件的线程列表上并对互斥量解锁.这样线程就不会错过任何条件变化.当函数返回时,该条件再次
      被加锁.

    • timedwait对wait增加了超时限制(tsptr参数).

  • 唤醒:

    #include<pthread.h>

    int pthread_cond_signal(pthread_cond_t *cond);

    int pthread_cond_broadcast(pthread_cond_t *cond);

    //成功则返回0,失败则返回错误代码
    • 通过这两个函数唤醒等待条件的进程,signal至少能唤醒一个,而broadcast则能唤醒全部等待条件的进程.

自旋锁

  • 自旋锁与mutex类似,都是通过阻塞的形式阻止获得已被加锁的锁.

  • mutex被阻塞时直接陷入睡眠等待信号(sleep-waiting),而自旋锁则是忙等待(busy-waiting),也就是说自旋锁的等待是占用CPU的

  • 自旋锁初始化:

    #include<pthread.h>

    int pthread_spin_init(pthread_spinlock_t *lock, int pshared);

    int pthread_spin_destroy(pthread_spinlock_t *lock);
    //两个函数若成功则返回0,否则返回错误编号
    • pshared参数:设置为PTHREAD_PROCESS_SHARED则可以被不同的进程共享,PTHREAD_PROCESS_PRIVATE只能被初始化锁的内部线程所访问
  • 函数原型:

    #include<pthread.h>

    int pthread_spin_lock(pthread_spinlock_t *lock);

    int pthread_spin_trylock(pthread_spinlock_t *lock);

    int pthread_spin_unlock(pthread_spinlock_t *lock);

    //所有函数如果成功则返回0,失败返回错误编号
  • 需要注意的是,使用自旋锁的范围其实很窄,除非频繁切换线程的开销非常大或者我们持有锁的时间非常短,否则使用自旋锁对cpu的占用其实很高.

屏障

  • 屏障是用户协调多个线程并行工作的同步机制.屏障允许每个线程等待,直到所有的线程都到达某一点(和go里面的WaitGroup一样).

  • 初始化:

    #include<pthread.h>

    int pthread_barrier_init(pthread_barrier_t *restrict barrier, const pthread_barrierattr_t *restrict attr,unsigned int count);

    int pthread_barrier_destroy(pthread_barrier_t *barrier);
    //两个函数若成功则返回0,否则返回错误编号
  • 函数原型:

    #include<pthread.h>

    int pthread_barrier_wait(pthread_barrier_t *barrier);
    //成功则返回0或者PTHREAD_BARRIER_SEGIAL_THREAD,否则返回错误编号
  • 调用wait的函数会在条件不满足时阻塞,直到count计数满足之后所有线程被唤醒.

  • 需要注意的是,到达屏障计数后屏障可以被重置,但此时的屏障计数没有改变.所以我们想要重用需要destroy然后再init初始化.