海哥 发表于 2024-8-15 10:44:25

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

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就是一个条件变量(队伍)
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提供了如下条件变量的等候接口:
https://i-blog.csdnimg.cn/direct/18ab28068f0e442c9b3288a8a77a53d6.png        函数描述:这两个函数的作用其实是一样的。我们下面会具体介绍。
两个函数的区别:

[*]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)!!这个时候就看哪个手速快了
唤醒等候的函数有以下两个:
#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
https://i-blog.csdnimg.cn/direct/0715ec69d4274151a1232603628e3087.png
        多个实行流向表现器打印,就是往文件里写入,多线程或多进程往同一个文件写入,这个文件就是一种临界资源,不加掩护的话,非常容易出现信息干扰。
#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);
} https://i-blog.csdnimg.cn/direct/84b3a141d38a49bd8a83c458532a0fc1.png
我们给打印这条语句加了锁,打印出来的效果也自然不会混在一起了
好了,我今天想说的主角可不是屏幕,而是我们的++操纵
我们接下来用上我们的条件变量
#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;
        }
} https://i-blog.csdnimg.cn/direct/20daabdf07d94a7fa04c15efce44b411.png
https://i-blog.csdnimg.cn/direct/5381cbd26e4d4e289d7cf0c7907adff6.png
      此时我们会发现唤醒这4个线程时具有明显的顺序性,根本原因是当这多少个线程启动时默认都会在该条件变量下去等候,而我们每次都唤醒的是在当前条件变量下等候的头部线程,当该线程实行完打印操纵后会继续排到等候队列的尾部举行wait,所以我们可以或许看到一个周转的征象。
我们可以唤醒所有线程
#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;
        }
} https://i-blog.csdnimg.cn/direct/633e1e0b7f1d4bb5b048544994f91479.png
https://i-blog.csdnimg.cn/direct/7013250a0df14696b225931ba520902a.png
   我为什么要让一个线程去休眠?


[*]一定是临界资源没有停当,没错,临界资源也是有状态的
    你怎么知道临界资源是停当还是不停当的?你判定出来的!那判定是访问临界资源吗? 是的,必须是的
            我们需要判定临界资源状态,就得访问临界资源,而我们的线程对临界资源是会修改的,这就注定了这个判定一定要在加锁和解锁之间,如许子别的线程就不能修改我们的临界资源,我们的判定效果也会是正确的
也就是必须是下面这种结构

void* Count(void*args)
{

        while(1)
        {
                pthread_mutex_lock(&mutex);//加锁
               
      pthread_cond_wait(&cond,&mutex);//判断资源情况,
               

                pthread_mutex_unlock(&mutex);//解锁

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

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: 【Linux】多线程4——线程同步/条件变量