【Java并发编程】synchronized(四):信号量、互斥量原理分析对比

1.信号量

信号量是用来协调不同进程间的数据对象的,而最主要的应用是共享内存方式的进程间通信。本质上,信号量是一个计数器,它用来记录对某个资源(如共享内存)的存取状况。

一般说来,为了获得共享资源,进程需要执行下列操作:

  1. 测试控制该资源的信号量
  2. 若此信号量的值为正,则允许进行使用该资源。进程将信号量减 1
  3. 若此信号量为 0,则该资源目前不可用,进程进入睡眠状态,直至信号量值大于0,进程被唤醒,转入步骤 1
  4. 当进程不再使用一个信号量控制的资源时,信号量值加1。如果此时有进程正在睡眠等待此信号量,则唤醒此进程

1.1 Semaphore 结构

维护信号量状态的是 Linux 内核操作系统而不是用户进程。我们可以从头文件 /usr/src/linux/include/linux/sem.h 中看到内核用来维护信号量状态的各个结构的定义。信号量是一个数据集合,用户可以单独使用这一集合的每个元素。要调用的第一个函数是 semget,用以获得一个信号量 ID。

Linux2.6.26 下定义的信号量结构体:

struct semaphore {
    spinlock_t lock; // 自旋锁
    unsigned int count; // 计数
    struct list_head wait_list; // 等待队列(保存休眠的进程)
};

从以上信号量的定义中,可以看到信号量底层使用到了 spin lock 的锁定机制,这个 spinlock 主要用来确保对 count 成员的原子性的操作(count–)和测试(count > 0)。

PS:从 semaphore 的结构也可以看出来,实现一个锁的本质就是,计数器 = 计数 + 保证线程安全 + 等待队列

  1. 申请资源时,count–,如果申请不到就放入条件队列中休眠
  2. 释放资源时,count++,如果等待队列中有等待唤醒的进程,就唤醒一个进程再来申请资源(一般是队列中第一个)

JUC 的 AQS 的结构及实现原理基本就是这样,只不过在保证它除了有同步队列还有个条件队列。

1.2 P 操作原理

P 操作就是其实就是申请获取资源(lock,加锁)。

相关函数如下:

// 申请信号量,当申请不到时会进程会休眠
void down(struct semaphore *sem); 
// 如果当进程因申请不到信号量而进入睡眠后,能被信号打断,但这时候这个函数的返回值不为0
// 上面所说的信号是指进程间通信的信号,比如我们的Ctrl+C
int down_interruptible(struct semaphore *sem); 
// 尝试去获得一个信号量,如果没有获得,函数立刻返回1,而不会让当前进程进入睡眠状态
int down_trylock(struct semaphore *sem);

down_interruptible 的源码如下:

int down_interruptible(struct semaphore *sem)
{
        unsigned long flags;
        int result = 0;
  		
  		// 保证是在原子操作的前提下,以确保别的进程能否获得该信号量
        spin_lock_irqsave(&sem->lock, flags);
        // 先测试count是否大于0,如果是说明可以获得信号量,这种情况下需要先将count--
        if (likely(sem->count > 0))
                sem->count--;
        // 如果没有获得信号量,当前进程利用struct semaphore 中 wait_list 加入等待队列,开始睡眠
        else
                result = __down_interruptible(sem);
        spin_unlock_irqrestore(&sem->lock, flags);
  		
  		// 函数返回,其调用者开始进入临界区
        return result;
}

对于需要休眠的情况,在__down_interruptible() 函数中,会构造一个 struct semaphore_waiter类型的变量(struct semaphore_waiter定义如下:

struct semaphore_waiter
{
    struct list_head list;
    struct task_struct *task;
    int up;
};

将当前进程赋给 task,并利用其 list 成员将该变量的节点加入到以 sem 中的 wait_list 为头部的一个列表中,假设有多个进程在 sem 上调用 down_interruptible,则 sem 的 wait_list 上形成的队列如下图:

在这里插入图片描述

注:将一个进程阻塞,一般的经过是先把进程放到等待队列中,接着改变进程的状态,比如设为 TASK_INTERRUPTIBLE(不可中断休眠),然后调用调度函数 schedule(),后者将会把当前进程从 cpu 的运行队列中摘下

1.3 V 操作原理

V 操作就是释放资源(unlock,释放锁),唤醒在等待的进程。

相关函数如下:

void up(struct semaphore *sem);

up 的源码如下:

void up(struct semaphore *sem)
{
    unsigned long flags;
    spin_lock_irqsave(&sem->lock, flags);
    // 如果没有其他线程等待在目前即将释放的信号量上,那么只需将 count++ 即可
    if (likely(list_empty(&sem->wait_list)))
            sem->count++;
    // 如果有其他线程正因为等待该信号量而睡眠,那么调用__up唤醒
    else
            __up(sem);
    spin_unlock_irqrestore(&sem->lock, flags);
}

__up 的定义:

static noinline void __sched __up(struct semaphore *sem)
{	
	// 首先获得sem所在的wait_list为头部的链表的第一个有效节点
    struct semaphore_waiter *waiter = list_first_entry(&sem->wait_list, struct semaphore_waiter, list);
    // 然后从链表中将其删除
    list_del(&waiter->list);
    waiter->up = 1;
    // 然后唤醒该节点上睡眠的进程
    wake_up_process(waiter->task);
}

由此可见,对于 sem 上的每次 down_interruptible 调用,都会在sem的wait_list链表尾部加入一新的节点。对于sem上的每次up调用,都会删除掉wait_list链表中的第一个有效节点,并唤醒睡眠在该节点上的进程。

1.4 信号量使用示例

信号量使用流程:

// 1.分配信号量对象
struct semaphore sema;

// 2.初始化为互斥信号量
init_MUTEX(&sema);
或者:
DECLARE_MUTEX(sema);

// 3.访问临界区之前获取信号量
down(&sema); // 如果获取到信号量,立即返回。如果信号量不可用,进程将在此休眠,并且休眠的状态是[不可中断的休眠状态 TASK_UNINTERRUPTIBLE]
或者
down_interruptible(&sema); // 如果信号量不可用,进程将进入[可中断的休眠状态 TASK_INTERRUPTIBLE],如果返回0表示正常获取信号,如果返回非0,表示接受到了信号
或者
down_trylock(); // 获取信号,如果信号量不可用,返回非0,如果信号量可用,返回0;不会引起休眠,可以在中断上下文使用。返回值也要做判断
  
// 4.访问临界区:临界区可以休眠

// 5.释放信号量
up(&sema);  // 不仅仅释放信号量,然后唤醒休眠的进程,让这个进程去获取信号量来访问临界区

下面举一个生产者消费者的例子:

item buffer[n]; // n个缓冲区组成的数组;
int in = 0, out = 0; // in: 输入指针, out: 输出指针;

// 信号量不允许直接参与运算, 故都要定义;
semaphore mutex = 1; // mutex: 互斥信号量, 生产者进程和消费者进程都只能互斥访问缓冲区;
semaphore full = 0;  // full: 资源信号量, 满缓冲区的数量;
semaphore empty = n; // empty: 资源信号量, 空缓冲区的数量;

// 生产者程序;
void Producer() {
  do {
      生产者生产一个产品nextp;
      P(empty); // 申请一个空缓冲区;
      P(mutex); // 申请临界资源;
      buffer[in] = nextp; // 将产品添加到缓冲区;
      in = (in + 1) % n;  // 类似于循环队列;
      V(mutex); // 释放临界资源;
      V(full);  // 释放一个满缓冲区;
  } while (TRUE);
}
 
// 消费者程序;
void Producer() {
  do {
      P(full);  // 申请一个满缓冲区;
      P(mutex); // 申请临界资源;
      nextc = buffer[out]; // 将产品从缓冲区取出;
      out = (out + 1) % n; // 类似于循环队列;
      V(mutex); // 释放临界资源;
      V(empty); // 释放一个空缓冲区;
      消费者将一个产品nextc消费; 
 } while (TRUE);
}
  • 需要注意的是:应先执行对资源信号量的申请,然后再对互斥信号量进行申请操作,否则会因起死锁。

首先缓冲区有n个,每个都有满(full)和和空(empty)的两种状态,临界区只有一个,控制权为mutex,值为1

  1. 如果生产者首先拿到的是缓冲区的控制权,那么其他的生产者和消费者就拿不到这个控制权了
  2. 接着这个生产者去申请一个空的缓冲区,如果此时这些个缓冲区刚好是满的,那么这个申请必然失败
  3. 那么消费者还能消费吗?他拿不到临界区的控制权,无法消费,也就没有空的缓冲区腾出来,生产者申请空缓冲区也必然不成功,此时就是一个死锁的状态了。

更多互斥量示例可以参考这篇文章

2 互斥量

互斥体实现了“互相排斥”(mutual exclusion)同步的简单形式(所以名为互斥体(mutex))。互斥体禁止多个线程同时进入受保护的代码“临界区”(critical section)。因此,在任意时刻,只有一个线程被允许进入这样的代码保护区。

任何线程在进入临界区之前,必须获取(acquire)与此区域相关联的互斥体的所有权。如果已有另一线程拥有了临界区的互斥体,其他线程就不能再进入其中。这些线程必须等待,直到当前的属主线程释放(release)该互斥体。

2.1 Mutex 结构

Linux 2.6.26 中 mutex 的定义:

struct mutex {
        /* 1: unlocked, 0: locked, negative: locked, possible waiters */
        atomic_t                  count;
        spinlock_t                wait_lock;
        struct list_head          wait_list;
#ifdef CONFIG_DEBUG_MUTEXES
        struct thread_info        *owner;
        const char                *name;
        void                      *magic;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
        struct lockdep_map         dep_map;
#endif
};

对比前面的 struct semaphore,struct mutex 除了增加了几个作为 debug 用途的成员变量外,和 semaphore 几乎长得一样。但是mutex 的引入主要是为了提供互斥机制,以避免多个进程同时在一个临界区中运行。

  • 如果静态声明一个 count=1 的 semaphore 变量,可以使用DECLARE_MUTEX(name)
  • 如果要定义一个静态 mutex 型变量,应该使用 DEFINE_MUTEX

如果在程序运行期要初始化一个mutex变量,可以使用mutex_init(mutex),mutex_init是个宏,在该宏定义的内部,会调用__mutex_init函数。

#define mutex_init(mutex) \
do { \
    static struct lock_class_key __key; \
    \
    __mutex_init((mutex), #mutex, &__key); \
} while (0)
  
void __mutex_init(struct mutex *lock, const char *name, struct lock_class_key *key)
{
    atomic_set(&lock->count, 1);
    spin_lock_init(&lock->wait_lock);
    INIT_LIST_HEAD(&lock->wait_list);
    debug_mutex_init(lock, name, key);
  
}

从__mutex_init的定义可以看出,在使用mutex_init宏来初始化一个mutex变量时,应该使用mutex的指针型。

2.2 互斥量的 PV 操作

void mutex_lock(struct mutex *lock);
void __sched mutex_unlock(struct mutex *lock);

从原理上讲,mutex实际上是count=1情况下的semaphore,所以其PV操作应该和semaphore是一样的。但是在实际的Linux代码上,出于性能优化的角度,并非只是单纯的重用down_interruptible和up的代码。

以 ARM 平台的 mutex_lock 为例,实际上是将 mutex_lock 分成两部分实现:fast path和slow path,主要是基于这样一个事实:在绝大多数情况下,试图获得互斥体的代码总是可以成功获得。所以 Linux 的代码针对这一事实用 ARM V6 上的 LDREX 和 STREX 指令来实现 fast path 以期获得最佳的执行性能。

mutux 底层支持:

1) Linux:底层的 pthread mutex采用futex(2)(fast userspace mutex)实现,不必每次加锁、解锁都陷入系统调用(从用户态切换到内核态)。

futex(2):快速用户态互斥锁(fast userspace mutex),在非竞态(或非锁争用,表示申请即能立即拿到锁而不用等待)的情况下,futex操作完全在用户态下进行,内核态只负责处理竞态(或锁争用,表示申请了但有其它线程提前拿到了锁,需要等待锁被释放后才能拿到)下的操作(调用相应的系统调用来仲裁),futex有两个主要的函数:

futex_wait(addr, val) // 检查*addr == val ?,若相等则将当前线程放入等待队列addr中随眠(陷入到内核态等待唤醒),否则调用失>败并返回错误(依旧处于用户态)
futex_wake(addr, val) // 唤醒val个处于等待队列addr中的线程(从内核态回到用户态

因此采用futex(2)的互斥锁不必每次加解锁都从用户态切换到内核态,效率较高.

2)Windows:底层的 CRITICAL_SECTION 嵌入了一小段自旋锁,如果不能立即拿到锁,它先会自旋一小段时间,如果还拿不到,才挂起当前线程.

2.3 互斥量使用示例

互斥量使用流程:

#include<pthread.h> // 以下方法都是成功返回0,失败返回-1

int pthread_mutex_init(pthread_mutex_t *mutex,pthread_mutexattr *attr);  // 初始化锁

int pthread_mutex_lock(pthread_mutex_t *mutex);   // 对资源加锁,阻塞。
int pthread_mutex_trylock(pthread_mutex_t*mutex); // 对资源加锁,非阻塞

// 临界区...
// 临界区...

int pthread_mutex_unlock(pthread_mutex_t *mutex);  // 对资源解锁

int pthread_mutex_destroy(pthread_mutex_t *mutex);  // 销毁锁                

3.互斥量和信号量区别

1)概念上的区别:

  • 信号量:是进程间(线程间)同步用的,一个进程(线程)完成了某一个动作就通过信号量告诉别的进程(线程),别的进程(线程)再进行某些动作。有二值和多值信号量之分。
  • 互斥锁:是线程间互斥用的,一个线程占用了某一个共享资源,那么别的线程就无法访问,直到这个线程离开,其他的线程才开始可以使用这个共享资源。可以把互斥锁看成二值信号量。

2)上锁时:

  • 信号量: 只要信号量的 value 大于0,其他线程就可以 sem_wait 成功,成功后信号量的 value 减一。若value值不大于0,则sem_wait阻塞,直到sem_post释放后value值加一。
  • 互斥锁: 只要被锁住,其他任何线程都不可以访问被保护的资源。如果没有锁,获得资源成功,否则进行阻塞等待资源可用。

3)使用场景:

  • 信号量主要适用于进程间通信,当然,也可用于线程间互斥
  • 互斥锁只能用于线程间互斥

信号量/互斥体和自旋锁的区别?

信号量/互斥体允许进程睡眠属于睡眠锁,自旋锁则不允许调用者睡眠,而是让其循环等待(忙等),所以有以下区别应用 :

  1. 信号量和读写信号量适合于保持时间较长的情况,它们会导致调用者睡眠,因而自旋锁适合于保持时间非常短的情况
  2. 自旋锁可以用于中断,不能用于进程上下文(会引起死锁)。而信号量不允许使用在中断中,而可以用于进程上下文
  3. 自旋锁保持期间是抢占失效的,自旋锁被持有时,内核不能被抢占,而信号量和读写信号量保持期间是可以被抢占的

另外需要注意的是:

  1. 信号量锁保护的临界区可包含可能引起阻塞的代码,而自旋锁则绝对要避免用来保护包含这样代码的临界区,因为阻塞意味着要进行进程的切换,如果进程被切换出去后,另一进程企图获取本自旋锁,死锁就会发生。
  2. 在你占用信号量的同时不能占用自旋锁,因为在你等待信号量时可能会睡眠,而在持有自旋锁时是不允许睡眠的。
  •  

猜你喜欢

转载自blog.csdn.net/qq_33762302/article/details/114297727