多线程的同步与互斥

学习https://blog.csdn.net/daaikuaichuan/article/details/82950711

一、同步与互斥的概念

现代操作系统基本都是多任务操作系统,即同时有大量可调度实体在运行。在多任务操作系统中,同时运行的多个任务可能:

都需要访问/使用同一种资源;
多个任务之间有依赖关系,某个任务的运行依赖于另一个任务。
同步】:

是指散步在不同任务之间的若干程序片断,它们的运行必须严格按照规定的某种先后次序来运行,这种先后次序依赖于要完成的特定的任务。最基本的场景就是:两个或两个以上的进程或线程在运行过程中协同步调,按预定的先后次序运行。比如 A 任务的运行依赖于 B 任务产生的数据。

【互斥】:

是指散步在不同任务之间的若干程序片断,当某个任务运行其中一个程序片段时,其它任务就不能运行它们之中的任一程序片段,只能等到该任务运行完这个程序片段后才可以运行。最基本的场景就是:一个公共资源同一时刻只能被一个进程或线程使用,多个进程或线程不能同时使用公共资源。

二、互斥锁(同步)

在多任务操作系统中,同时运行的多个任务可能都需要使用同一种资源。这个过程有点类似于,公司部门里,我在使用着打印机打印东西的同时(还没有打印完),别人刚好也在此刻使用打印机打印东西,如果不做任何处理的话,打印出来的东西肯定是错乱的。
在线程里也有这么一把锁——互斥锁(mutex),互斥锁是一种简单的加锁的方法来控制对共享资源的访问,互斥锁只有两种状态,即上锁( lock )和解锁( unlock )。

【特点】

  1. 原子性:把一个互斥量锁定为一个原子操作,这意味着操作系统(或pthread函数库)保证了如果一个线程锁定了一个互斥量,没有其他线程在同一时间可以成功锁定这个互斥量;

  2. 唯一性:如果一个线程锁定了一个互斥量,在它解除锁定之前,没有其他线程可以锁定这个互斥量;

  3. 非繁忙等待:如果一个线程已经锁定了一个互斥量,第二个线程又试图去锁定这个互斥量,则第二个线程将被挂起(不占用任何cpu资源),直到第一个线程解除对这个互斥量的锁定为止,第二个线程则被唤醒并继续执行,同时锁定这个互斥量。

【操作流程】

  1. 在访问共享资源后临界区域前,对互斥锁进行加锁;
  2. 在访问完成后释放互斥锁导上的锁。在访问完成后释放互斥锁导上的锁;
  3. 对互斥锁进行加锁后,任何其他试图再次对互斥锁加锁的线程将会被阻塞,直到锁被释放。对互斥锁进行加锁后,任何其他试图再次对互斥锁加锁的线程将会被阻塞,直到锁被释放。

    【函数】

#include "pthread.h"
#include "time.h"
// 初始化一个互斥锁。
int pthread_mutex_init(pthread_mutex_t *mutex,
const pthread_mutexattr_t *attr);

// 对互斥锁上锁,若互斥锁已经上锁,则调用者一直阻塞,
// 直到互斥锁解锁后再上锁。
int pthread_mutex_lock(pthread_mutex_t *mutex);

// 调用该函数时,若互斥锁未加锁,则上锁,返回 0;
// 若互斥锁已加锁,则函数直接返回失败,即 EBUSY。
int pthread_mutex_trylock(pthread_mutex_t *mutex);

// 当线程试图获取一个已加锁的互斥量时,pthread_mutex_timedlock 互斥量
// 原语允许绑定线程阻塞时间。即非阻塞加锁互斥量。
int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex,
const struct timespec *restrict abs_timeout);

// 对指定的互斥锁解锁。
int pthread_mutex_unlock(pthread_mutex_t *mutex);

// 销毁指定的一个互斥锁。互斥锁在使用完毕后,
// 必须要对互斥锁进行销毁,以释放资源。
int pthread_mutex_destroy(pthread_mutex_t *mutex);

【实例1】

//使用互斥量解决多线程抢占资源的问题
#include "stdio.h"
#include "stdlib.h"
#include "unistd.h"
#include "pthread.h"
#include "string.h"

char* buf[5]; //字符指针数组 全局变量
int pos; //用于指定上面数组的下标

//1.定义互斥量
pthread_mutex_t mutex;

void *task(void *p)
{
//3.使用互斥量进行加锁
pthread_mutex_lock(&mutex);

buf[pos] = (char *)p;
sleep(1);
pos++;

//4.使用互斥量进行解锁
pthread_mutex_unlock(&mutex);
}

int main(void)
{
//2.初始化互斥量, 默认属性
pthread_mutex_init(&mutex, NULL);

//1.启动一个线程 向数组中存储内容
pthread_t tid, tid2;
pthread_create(&tid, NULL, task, (void *)"zhangfei");
pthread_create(&tid2, NULL, task, (void *)"guanyu");
//2.主线程进程等待,并且打印最终的结果
pthread_join(tid, NULL);
pthread_join(tid2, NULL);

//5.销毁互斥量
pthread_mutex_destroy(&mutex);

int i = 0;
printf("字符指针数组中的内容是:");
for(i = 0; i < pos; ++i)
{
printf("%s ", buf[i]);
}
printf("\n");
return 0;
}

【实例2】

#include "stdio.h"
#include "pthread.h"
#include "time.h"
#include "string.h"

int main (void)
{
int err;
struct timespec tout;
struct tm *tmp;
char buf[64];
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

pthread_mutex_lock (&lock);
printf ("mutex is locked\n");
clock_gettime (CLOCK_REALTIME, &tout);
tmp = localtime (&tout.tv_sec);
strftime (buf, sizeof (buf), "%r", tmp);
printf ("current time is %s\n", buf);
tout.tv_sec += 10;
err = pthread_mutex_timedlock (&lock, &tout);
clock_gettime (CLOCK_REALTIME, &tout);
tmp = localtime (&tout.tv_sec);
strftime (buf, sizeof (buf), "%r", tmp);
printf ("the time is now %s\n", buf);
if (err == 0)
printf ("mutex locked again\n");
else
printf ("can`t lock mutex again:%s\n", strerror (err));
return 0;
}

三、条件变量(同步)

【特点】

与互斥锁不同,条件变量是用来等待而不是用来上锁的。条件变量用来自动阻塞一个线程,直到某特殊情况发生为止。通常条件变量和互斥锁同时使用。
条件变量使我们可以睡眠等待某种条件出现。条件变量是利用线程间共享的全局变量进行同步 的一种机制,主要包括两个动作:

一个线程等待”条件变量的条件成立”而挂起;
另一个线程使 “条件成立”(给出条件成立信号)。

【原理】

  条件的检测是在互斥锁的保护下进行的。线程在改变条件状态之前必须首先锁住互斥量。如果一个条件为假,一个线程自动阻塞,并释放等待状态改变的互斥锁。如果另一个线程改变了条件,它发信号给关联的条件变量,唤醒一个或多个等待它的线程,重新获得互斥锁,重新评价条件。如果两进程共享可读写的内存,条件变量 可以被用来实现这两进程间的线程同步。

【条件变量的操作流程如下】

  1. 初始化:init()或者pthread_cond_tcond=PTHREAD_COND_INITIALIER;属性置为NULL;

  2. 等待条件成立:pthread_wait,pthread_timewait.wait()释放锁,并阻塞等待条件变量为真 timewait()设置等待时间,仍未signal,返回ETIMEOUT(加锁保证只有一个线程wait);

  3. 激活条件变量:pthread_cond_signal,pthread_cond_broadcast(激活所有等待线程)

  4. 清除条件变量:destroy;无线程等待,否则返回EBUSY清除条件变量:destroy;无线程等待,否则返回EBUSY

【实例】

生产者和消费者模型,生产者向队列中插入数据,消费者则在生产者发出队列准备好(有数据了)后接收消息,然后取出数据进行处理。实现的关键点在以下几个方面:

  • 生产者和消费者都对条件变量的使用加了锁
  • 消费者调用pthread_cond_wait,等待队列是否准备好的信息,注意参数有两个,一个是pthread_cond_t,另外一个是pthread_mutex_t.

代码:

#include "pthread.h"
struct msg {
struct msg *m_next;
/* ... more stuff here ... */
};
struct msg *workq;
pthread_cond_t qready = PTHREAD_COND_INITIALIZER;
pthread_mutex_t qlock = PTHREAD_MUTEX_INITIALIZER;
void
process_msg(void)
{
struct msg *mp;
for (;;) {
pthread_mutex_lock(&qlock);
while (workq == NULL)
pthread_cond_wait(&qready, &qlock);
mp = workq;
workq = mp->m_next;
pthread_mutex_unlock(&qlock);
/* now process the message mp */
}
}
void
enqueue_msg(struct msg *mp)
{
pthread_mutex_lock(&qlock);
mp->m_next = workq;
workq = mp;
pthread_mutex_unlock(&qlock);
pthread_cond_signal(&qready);
}

【疑问】

为什么pthread_cond_wait需要加锁??

pthread_cond_wait中的mutex用于保护条件变量,调用这个函数进行等待条件的发生时,mutex会被自动释放,以供其它线程(生产者)改变条件,pthread_cond_wait中的两个步骤必须是原子性的(atomically,万恶的APUE中文版把这个单词翻译成了『自动』,误人子弟啊),也就是说必须把两个步骤捆绑到一起:

  • 把调用线程放到条件等待队列上
  • 释放mutex

不然呢,如果不是原子性的,上面的两个步骤中间就可能插入其它操作。比如,如果先释放mutex,这时候生产者线程向队列中添加数据,然后signal,之后消费者线程才去『把调用线程放到等待队列上』,signal信号就这样被丢失了。

如果先把调用线程放到条件等待队列上,这时候另外一个线程发送了pthread_cond_signal(我们知道这个函数的调用是不需要mutex的),然后调用线程立即获取mutex,两次获取mutex会产生deadlock.

在生产者线程中修改条件时为什么要加mutex??

如果不这么做信号可能会丢失,看下面的例子:

Thead A                             Thread B

pthread_mutex_lock(&qlock);
while (workq == NULL)
mp->m_next = workq;
workq = mp;
pthread_cond_signal(&cond);

pthread_cond_wait(&qready, &qlock);

在while判断之后向队列中插入数据,虽然已经有数据了,但线程A还是调用了pthread_cond_wait等待下一个信号到来。。

消费者线程中判断条件为什么要放在while中??

while (workq == NULL)
pthread_cond_wait(&qready, &qlock);
mp = workq;

我们把while换成if可不可以呢?

if (workq == NULL)
pthread_cond_wait(&qready, &qlock);
mp = workq;

答案是不可以,一个生产者可能对应着多个消费者,生产者向队列中插入一条数据之后发出signal,然后各个消费者线程的pthread_cond_wait获取mutex后返回,当然,这里只有一个线程获取到了mutex,然后进行处理,其它线程会pending在这里,处理线程处理完毕之后释放mutex,刚才等待的线程中有一个获取mutex,如果这里用if,就会在当前队列为空的状态下继续往下处理,这显然是不合理的。

signal到底是放在unlock之前还是之后??

void
enqueue_msg(struct msg *mp)
{
pthread_mutex_lock(&qlock);
mp->m_next = workq;
workq = mp;
pthread_mutex_unlock(&qlock);
pthread_cond_signal(&qready);
}

如果先unlock,再signal,如果这时候有一个消费者线程恰好获取mutex,然后进入条件判断,这里就会判断成功,从而跳过pthread_cond_wait,下面的signal就会不起作用;另外一种情况,一个优先级更低的不需要条件判断的线程正好也需要这个mutex,这时候就会转去执行这个优先级低的线程,就违背了设计的初衷。

    void
enqueue_msg(struct msg *mp)
{
pthread_mutex_lock(&qlock);
mp->m_next = workq;
workq = mp;
pthread_cond_signal(&qready);
pthread_mutex_unlock(&qlock);
}

如果把signal放在unlock之前,消费者线程会被唤醒,获取mutex发现获取不到,就又去sleep了。浪费了资源.但是在LinuxThreads或者NPTL里面,就不会有这个问题,因为在Linux 线程中,有两个队列,分别是cond_wait队列和mutex_lock队列, cond_signal只是让线程从cond_wait队列移到mutex_lock队列,而不用返回到用户空间,不会有性能的损耗。
所以在Linux中推荐使用这种模式。