【Linux】多线程4——线程同步/条件变量

掘根 2024-08-15 16:35:01 阅读 99

1.Linux线程同步

1.1.同步概念与线程饥饿问题

先来理解同步的概念

什么是线程同步

        在一般情况下,创建一个线程是不能提高程序的执行效率的,所以要创建多个线程。但是多个线程同时运行的时候可能调用线程函数,在多个线程同时对同一个内存地址进行写入,由于CPU时间调度上的问题,写入数据会被多次的覆盖,所以就要使线程同步。

同步就是协同步调,按预定的先后次序进行运行。如:你说完,我再说。

“同”字从字面上容易理解为一起动作,但其实不是,“同”字应是指协同、协助、互相配合。

        如进程、线程同步,可理解为进程或线程A和B一块配合,A执行到一定程度时要依靠B的某个结果,于是停下来,示意B运行;B依言执行,再将结果给A;A再继续操作。

        所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回,同时其它线程也不能调用这个方法。按照这个定义,其实绝大多数函数都是同步调用(例如sin, isdigit等)。但是一般而言,我们在说同步、异步的时候,特指那些需要其他部件协作或者需要一定时间完成的任务。例如Window API函数SendMessage。该函数发送一个消息给某个窗口,在对方处理完消息之前,这个函数不返回。当对方处理完毕以后,该函数才把消息处理函数所返回的LRESULT值返回给调用者。

同步: 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步。竞态条件: 因为时序问题,而导致程序异常,我们称之为竞态条件。

线程饥饿问题

        首先需要明确的是,单纯的加锁是会存在某些问题的,如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题。

        单纯的加锁是没有错的,它能够保证在同一时间只有一个线程进入临界区,但它没有高效的让每一个线程使用这份临界资源。

        现在我们增加一个规则,当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后。

        增加这个规则之后,下一个获取到锁的资源的线程就一定是在资源等待队列首部的线程,如果有十个线程,此时我们就能够让这十个线程按照某种次序进行临界资源的访问。

        例如,现在有两个线程访问一块临界区,一个线程往临界区写入数据,另一个线程从临界区读取数据,但负责数据写入的线程的竞争力特别强,该线程每次都能竞争到锁,那么此时该线程就一直在执行写入操作,直到临界区被写满,此后该线程就一直在进行申请锁和释放锁。而负责数据读取的线程由于竞争力太弱,每次都申请不到锁,因此无法进行数据的读取,引入同步后该问题就能很好的解决。

1.2.条件变量

我们怎么实现线程同步呢?这需要学习Linux的条件变量。

什么是条件变量?

         条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。

        上面说的太复杂了,简单的来说就是让线程按顺序执行的一种机制。换句话说,我们可以把条件变量当作是队伍

怎么理解条件变量

我们可以通过一个故事来理解条件变量!!

        现在小明要在在一张桌子上放一个苹果,而旁边有一群蒙着眼睛的人,因为他们的眼睛被蒙着,他们如果想拿到这个苹果,就会时不时来桌子前摸一摸看看桌子是否有苹果,并且谁来桌子前摸苹果是无序的,这时的场面就很混乱,小明一看不行,于是小明就在桌子上放了个铃铛并且组织需要苹果的人排好队有苹果小明就会摇响铃铛,排在第一个的人就拿走苹果如果还想拿苹果,就要到队尾排队等待。此时混乱的场面就显得井然有序了。在本故事中,小明就是操作系统,苹果就是临界资源,一群蒙着眼睛都人就是多线程,铃铛就是条件变量,排队就是实现同步,摇响铃铛就是唤醒线程

        条件变量是用来等待线程而不是上锁的,条件变量通常和互斥锁一起使用。条件变量之所以要和互斥锁一起使用,主要是因为互斥锁的一个明显的特点就是它只有两种状态:锁定和非锁定,而条件变量可以通过允许线程阻塞和等待另一个线程发送信号来弥补互斥锁的不足,所以互斥锁和条件变量通常一起使用

        互斥量可以防止多个线程同时访问临界资源,而条件变量让每个线程先按先来后到的顺序排队,这个队伍可以理解为阻塞队列,里面每个线程都会被阻塞,直到被唤醒才能,继续执行剩下的代码。

我们回过头来理解互斥量和条件变量

        上面那个例子里面,小明每次只让大家拿苹果的时候每次只能有1个人来拿,这个就是互斥锁的功劳。

        接下来小明就在桌子上放了个铃铛并且组织需要苹果的人排好队有苹果小明就会摇响铃铛,排在第一个的人就拿走苹果,如果还想拿苹果,就要到队尾排队等待。这些就是条件变量的功劳。

        也就是说,条件变量就是那个队伍和它的运转机制

         条件变量是一种等待机制——就是上面的排队,条件变量的实现通常包含一个等待队列,用于存储那些正在等待条件变量的线程。每一个条件变量都有等待队列。一般对于条件变量会有两种操作:

wait操作 : 将自己阻塞在自己这个条件变量的等待队列里,唤醒一个等待者或者开放锁的互斥访问——也就是我们说的按照先来后到的顺序排队singal 操作 : 唤醒一个等待的线程(等待队列为空的话什么也不做)——也就是摇响铃铛这个操作

1.3.条件变量函数

 我们上面简单的理解条件变量就是那个队伍和它的运转机制,那么我们怎么用代码表示这个条件变量呢?条件变量的类型就是pthread_cond_t。例如下面的a就是一个条件变量(队伍)

<code>pthread_cond_t a;

接下来我们来学习怎么让这个队伍运转起来。

1.3.1.初始化条件变量

POSIX提供了两种初始化条件变量(可以简单理解为从此以后都要进行排队了)的方法。

第一种方法

初始化条件变量的函数叫做pthread_cond_init,该函数的函数原型如下:

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

参数说明:

cond:需要初始化的条件变量。attr:初始化条件变量的属性,一般设置为NULL即可。

返回值说明:

条件变量初始化成功返回0,失败返回错误码。

第二种方法

调用pthread_cond_init函数初始化条件变量叫做动态分配,除此之外,我们还可以用下面这种方式初始化条件变量,该方式叫做静态分配:

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

这个相当于调用函数pthread_cond_init()初始化,并且参数attr为NULL。 

1.3.2.销毁条件变量

销毁条件变量(可以简单理解以后再也不要排队了)的函数叫做pthread_cond_destroy,该函数的函数原型如下:

int pthread_cond_destroy(pthread_cond_t *cond);

参数说明:

cond:需要销毁的条件变量。

返回值说明:

条件变量销毁成功返回0,失败返回错误码。

销毁条件变量需要注意:

使用PTHREAD_COND_INITIALIZER初始化的条件变量不需要销毁。

1.3.3.等待条件变量满足——来了就得来排队

        当前线程一旦调用这些函数,当前线程就会排到条件变量cond的那个阻塞队列的最后一个去,并且进入阻塞状态,阻塞在这里,直到它收到一个针对该条件变量的信号(通过 pthread_cond_signal 或 pthread_cond_broadcast 发出)。

        简单的理解,就是来了就得排队啊,但是现在资源没准备好!!所以先等着吧,资源好了,队伍就会变短。

POSIX提供了如下条件变量的等待接口:

        函数描述:这两个函数的作用其实是一样的。我们下面会详细介绍。

两个函数的区别:

pthread_cond_wait函数调用成功后,会一直阻塞等待,直到条件变量被唤醒。而 pthread_cond_timedwait 函数只会等待指定的时间,时间到了之后,条件变量仍未被唤醒的话,会返回一个错误码ETIMEDOUT,该错误码定义在<errno.h>头文件。

参数说明:

cond:需要等待的条件变量。mutex:当前线程所处临界区对应的互斥锁。

返回值说明:

函数调用成功返回0,失败返回错误码。

pthread_cond_wait函数行为如下:

解锁互斥锁调用 pthread_cond_wait 的线程首先会释放(解锁)它当前持有的互斥锁。这一步是必要的,因为条件变量通常与互斥锁一起使用,以确保对共享数据的访问是同步的。解锁互斥锁允许其他线程获取该锁,从而可以安全地修改共享数据。加入等待队列在解锁互斥锁之后,调用 pthread_cond_wait 的线程会将自己添加到与该条件变量相关联的等待队列中。此时,线程进入阻塞状态,等待被唤醒。阻塞并等待信号线程在等待队列中保持阻塞状态,直到它收到一个针对该条件变量的信号(通过 pthread_cond_signal 或 pthread_cond_broadcast 发出)。需要注意的是,仅仅因为线程在等待队列中并不意味着它会立即收到信号;它必须等待直到有其他线程显式地发出信号。重新获取互斥锁:当线程收到信号并准备从 pthread_cond_wait 返回时,它首先会尝试重新获取之前释放的互斥锁。如果此时锁被其他线程持有,那么该线程会阻塞在互斥锁的等待队列中,直到获得锁为止。这一步确保了线程在继续执行之前能够重新获得对共享数据的独占访问权。检查条件:一旦线程成功获取到互斥锁,它会再次检查导致它调用 pthread_cond_wait 的条件是否现在满足。虽然通常认为在收到信号时条件已经满足,但这是一个编程错误的常见来源。正确的做法是在每次从 pthread_cond_wait 返回后都重新检查条件,因为可能有多个线程在等待相同的条件,或者条件可能在信号发出和线程被唤醒之间发生变化。返回并继续执行:如果条件满足,线程会从 pthread_cond_wait 返回,并继续执行后续的代码。如果条件仍然不满足,线程可以选择再次调用 pthread_cond_wait 进入等待状态,或者执行其他操作。

总的来说,让指定的条件变量进入等待状态,其工作机制是先解锁传入的互斥量,再让条件变量等待,从而使所在线程处于阻塞状态。这两个函数返回时,系统会确保该线程再次持有互斥量(加锁)。

        另外一个函数的行为和上面这个几乎毫无差别

1.3.4.唤醒等待——第一个先拿/大家都有拿

上面说完了条件等待,接下来介绍条件变量的唤醒。

调用完条件变量等待函数的线程处于阻塞状态,若要被唤醒,必须是其他线程来唤醒。

这个唤醒就相当于是

资源没准备好,大家正排队等着呢,

准备好了1份资源,排在条件变量cond的阻塞队列的第一个的线程就先拿资源(pthread_cond_signal)资源准备的很多,大家一起来拿(pthread_cond_broadcast)!!这个时候就看哪个手速快了

唤醒等待的函数有以下两个:

<code>#include <pthread.h>

int pthread_cond_signal(pthread_cond_t *cond);

int pthread_cond_broadcast(pthread_cond_t *cond);

必须在互斥锁的保护下使用相应的条件变量。否则对条件变量的解锁有可能发生在锁定条件变量之前,从而造成死锁。 

pthread_cond_signal 负责唤醒等待在条件变量cond上的一个线程,如果有多个线程等待,是唤醒哪一个呢

Linux内核会为每个条件变量维护一个等待队列,调用了 pthread_cond_wait 或 pthread_cond_timedwait 的线程会按照调用时间先后添加到该队列中。pthread_cond_signal会唤醒该队列的第一个。如果没有线程被阻塞在条件变量上,那么调用pthread_cond_signal()将没有作用。

        pthread_cond_broadcast,就是同时唤醒等待在条件变量上的所有线程。前面说过,条件等待的两个函数返回时,系统会确保该线程再次持有互斥量(加锁),所有,这里被唤醒的所有线程都会去争夺互斥锁,没抢到的线程会继续等待,拿到锁后同样会从条件等待函数返回。所以,被唤醒的线程第一件事就是再次判断条件是否满足!

        由于pthread_cond_broadcast函数唤醒所有阻塞在某个条件变量上的线程,这些线程被唤醒后将再次竞争相应的互斥锁,所以必须小心使用pthread_cond_broadcast函数。

参数说明:

cond:唤醒在cond条件变量下等待的线程。

返回值说明:

函数调用成功返回0,失败返回错误码。

问题:在调用pthread_cond_wait前如果已经提前收到唤醒通知会怎么样?

答:

        如果在调用pthread_cond_wait之前线程已经收到了条件变量的唤醒通知(通过pthread_cond_signal或pthread_cond_broadcast),那么该通知实际上会被“记住”,直到线程真正进入pthread_cond_wait并准备返回。

        这是因为条件变量的实现通常包含一个等待队列,用于存储那些正在等待条件变量的线程。

        当调用pthread_cond_signal或pthread_cond_broadcast时,会唤醒等待队列中的一个或多个线程,但如果没有线程实际在pthread_cond_wait中等待,那么这个通知就会被保留,直到有线程调用pthread_cond_wait。

1.4.使用示例

我们先下面这样子的

#include <stdio.h>

#include <pthread.h>

#include<iostream>

#include<unistd.h>

using namespace std;

int cnt=0;//临界资源

void* Count(void*args)

{

pthread_detach(pthread_self());//分离线程

long long number=(long long)args;

while(1)

{

cout<<"pthread: "<<number<<endl;

sleep(3);

}

}

int main()

{

for(long long i=0;i<5;i++)

{

pthread_t tid;

pthread_create(&tid,nullptr,Count,(void*)i);

}

while(1)

sleep(1);

}

特别注意:64位平台下面,int类型是4字节,不能和void*指针类型(8字节)进行相互转换 ,所以这里使用long long

        多个执行流向显示器打印,就是往文件里写入,多线程或多进程往同一个文件写入,这个文件就是一种临界资源,不加保护的话,非常容易出现信息干扰。

<code>#include <stdio.h>

#include <pthread.h>

#include<iostream>

#include<unistd.h>

using namespace std;

int cnt=0;//全局变量

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//互斥锁,不需要初始化和销毁

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//条件变量,不需要初始化和销毁

void* Count(void*args)

{

pthread_detach(pthread_self());//分离线程

long long number=(long long)args;

while(1)

{

pthread_mutex_lock(&mutex);//加锁

//先不管临界资源的情况

cout<<"pthread: "<<number<<" ,cnt :"<<cnt++<<endl;

pthread_mutex_unlock(&mutex);//解锁

sleep(1);

}

}

int main()

{

for(long long i=0;i<5;i++)

{

pthread_t tid;

pthread_create(&tid,nullptr,Count,(void*)i);

}

while(1)

sleep(1);

}

我们给打印这条语句加了锁,打印出来的结果也自然不会混在一起了

好了,我今天想说的主角可不是屏幕,而是我们的++操作

我们接下来用上我们的条件变量

<code>#include <stdio.h>

#include <pthread.h>

#include<iostream>

#include<unistd.h>

using namespace std;

int cnt=0;//全局变量

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//互斥锁,不需要初始化和销毁

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//条件变量,不需要初始化和销毁

void* Count(void*args)

{

pthread_detach(pthread_self());//分离线程

long long number=(long long)args;

cout<<"pthread: "<<number<<" creat success !"<<endl;

while(1)

{

pthread_mutex_lock(&mutex);//加锁

pthread_cond_wait(&cond,&mutex);//先让自己这个线程去条件变量cond的等待队列的最后一个位置等待

//当前线程之间进入阻塞状态,阻塞在这里,后续代码暂不执行,等待被唤醒之后再执行

//先不管临界资源的情况

cout<<"pthread: "<<number<<" ,cnt :"<<cnt++<<endl;

pthread_mutex_unlock(&mutex);//解锁

sleep(1);

}

}

int main()

{

for(long long i=0;i<5;i++)

{

pthread_t tid;

pthread_create(&tid,nullptr,Count,(void*)i);

usleep(1000);

}

sleep(3);

cout<<"main thread ctrl begin:"<<endl;

while(1)

{

sleep(1);//每过1秒就唤醒1次

pthread_cond_signal(&cond);//唤醒在cond的等待队列的1个线程,默认都是第1个

cout<<"signal one thread..."<<endl;

}

}

      此时我们会发现唤醒这4个线程时具有明显的顺序性,根本原因是当这若干个线程启动时默认都会在该条件变量下去等待,而我们每次都唤醒的是在当前条件变量下等待的头部线程,当该线程执行完打印操作后会继续排到等待队列的尾部进行wait,所以我们能够看到一个周转的现象。

我们可以唤醒所有线程

<code>#include <stdio.h>

#include <pthread.h>

#include<iostream>

#include<unistd.h>

using namespace std;

int cnt=0;//全局变量

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//互斥锁,不需要初始化和销毁

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//条件变量,不需要初始化和销毁

void* Count(void*args)

{

pthread_detach(pthread_self());//分离线程

long long number=(long long)args;

cout<<"pthread: "<<number<<" creat success !"<<endl;

while(1)

{

pthread_mutex_lock(&mutex);//加锁

pthread_cond_wait(&cond,&mutex);//先让别的线程去等待队列 //为什么填在这里?pthread_cond_wait让线程等待的时候会自动释放掉

//先不管临界资源的情况

cout<<"pthread: "<<number<<" ,cnt :"<<cnt++<<endl;

pthread_mutex_unlock(&mutex);//解锁

sleep(1);

}

}

int main()

{

for(long long i=0;i<5;i++)

{

pthread_t tid;

pthread_create(&tid,nullptr,Count,(void*)i);

usleep(1000);

}

sleep(3);

cout<<"main thread ctrl begin:"<<endl;

while(1)

{

sleep(1);

pthread_cond_broadcast(&cond);//唤醒在cond的等待队列的1个线程,默认都是第1个

cout<<"signal one thread..."<<endl;

}

}

我为什么要让一个线程去休眠?

一定是临界资源没有就绪,没错,临界资源也是有状态的

你怎么知道临界资源是就绪还是不就绪的?你判断出来的!那判断是访问临界资源吗? 是的,必须是的

        我们需要判断临界资源状态,就得访问临界资源,而我们的线程对临界资源是会修改的,这就注定了这个判断一定要在加锁和解锁之间,这样子别的线程就不能修改我们的临界资源,我们的判断结果也会是正确的

也就是必须是下面这种结构

<code>void* Count(void*args)

{

while(1)

{

pthread_mutex_lock(&mutex);//加锁

pthread_cond_wait(&cond,&mutex);//判断资源情况,

pthread_mutex_unlock(&mutex);//解锁

}

}

这也是我们为什么需要互斥量的原因 



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。