线程互斥与同步,线程全部内容

临界区和临界资源

第一个概念叫做,临界区和临界资源。这也是我们曾今埋下的小伏笔,当时在前面讲的时候就和大家说了,我们前面知识把这个概念说一说,当真正的再去用它的时候,我们就在多线程再细细来谈。我们当时在讲进程间通信讲过。
》1.临界资源、2.临界区、3.互斥
》临界资源对应的特点是:多个执行流都能看到并能访问的资源,叫做临界资源。
》第二个,临界区叫做,多个执行流代码中,你可以理解成有不同的代码,但是访问临界资源的代码,我们称之为,临界区。
》这两个概念,我们在代码当中,今天就能体现出来。比如说我们今天创建了一大批线程,然后我们有一个全局变量global_value。其中global_value呢可以看到,3个线程同时执行startRoutine()这样一个函数,当然你也可以用不同的回调函数。那么其中每一个线程都去访问这个global_value这样的全局数据,包括对我们这个全局数据做++操作,那么这个global_value在今天看来就是 临界资源。那么其中对我们来讲呢,这个startRoutine()函数,它里面可能执行其他代码,但是在执行有关global_value这一行代码时,访问我们所对应的 临界资源,那么我们把访问临界资源的代码,叫做 临界区。
》所以不是所有的代码都是临界区,访问临界资源的代码才是临界区。当我们访问临界资源的时候,可能会因为共同访问导致其他线程出现问题。或者可以这么理解,因为共同访问,涉及到读写,可能会造成数据不一致的问题。当然除了这么一个问题之外,还会影响其他线程的执行。下面一个问题是,有人会说,怎么可能会出现你那样的情况呢?你说的这数据不一致是什么意思呢?
》所以为了更方便的让同学们能够感受到所谈的这一点呢,我把代码做修改,讲startRoutine()函数注释掉。我们照样创建3个线程,join有没有我们不用管了。我们把global_value,改成int tickets = 100;改成抢票。假设我们现在有一个计数器,充当一个票数计数器,用这个票数计数器呢,来模拟一下多线程抢票的行为。讲个故事:在以前,买票的时候买不到,过年都挑好时候就在那里抢,抢的时候抢不到怎么办呢,有时候就会有软件公司比如360,当时出了一个360抢票王这么一个浏览器,然后用它这个功能推广浏览器。抢票对我们现阶段肯定能理解。我们就假设现在有若干个线程,分别在对这个票进行抢票。那抢票怎么抢呢?
》我们写一个getTickets()函数,即void
getTickets(void
args),其相当于新线程的回调函数,我们来一个const char
name线程的名字,同时还得对名字做强转,即const char
name = static_cast<void*>(args);强转之后就拿到了新线程的名字。抢票大家也知道,你得先判断有没有票,if(tickets > 0),那么其中对我们来讲呢,我们有多线程共同的去抢tickets,抢到一张必然是要tickets–的。然后else意味着当前没有票了。线程在抢的时候呢,从我们的代码来看,只抢了一次,而我想让他们一直去抢票,此时就得用while循环了,去循环执行我们的抢票逻辑。else表示没有票了,没有票之后就break跳出循环,然后return nullptr表示线程执行完了。
》那么其中这个tickets就是将来被所有的线程所共同访问的一个函数,那么其中新线程的代码里面, if里面的代码逻辑就是访问我们公共资源的代码,其中这份代码呢,我们就称作临界区。然后tickets呢我们就称作临界资源。那么对我们来讲呢,我们在抢票的时候,对这个tickets抢的逻辑都是每一个线程在–。问一下同学们,正常情况下,票抢到几张,就算没有了呢?很简单:0张的时候。当我们抢票的时候,如果此时我们没票了,此时每个线程都会退出。那么我们编译下代码并运行。我们最终看到,这个票确实被抢到了0张就没有了。
》现在来看呢,这个代码实际上是没有问题的,票呢大家其实都抢了,此时呈现出来的现象呢就是,线程3抢了一批,线程2抢了一批,一大批被线程2、3在抢。这个呢也很正常,因为在自己执行流的调度周期里面,如果他一直被调度,它就相当于其他线程在等待,在等待队列里等,没人跟他抢,所以直接抢的时候,一次性抢了好多,没人跟他抢嘛,这也正常。那么其中对我们来讲呢,在我们代码这里我们抢 票看起来是没问题的。
》但是,我想告诉大家,其中这个代码是有问题的。那么有什么问题呢?我先来问同学们一个问题,首先我们一定要想清楚一个问题,抢票最后本质上是既对票数做判断,做判断的时候呢,又对我们票进行–。相信同学们这个是能理解的。同学们,我们就以这个–操作为例,下面我问同学一个问题,这个问题曾经说过一次,这次再正式说一下。我们定义一个全局变量tickets,int tickets;然后对**tickets–,是由一条语句完成的嘛?**也就是说,对我们来讲呢,tickets–的时候是不是就由一条语句完成。相当于什么呢?相当于,这里的if判断也是计算,暂时不考虑这个if里面的判断,就考虑–操作,其中它是一条语句完成的嘛?答案是:并不是!你要记住,你学的是C/C++语言,tickets它是一个语言级别的–操作,而实际上呢,它的语言级别的语法呢,最终被我们编译器编译,要至少翻译成3条语句,哪3条呢?我们回过头来问同学们一个问题。因为计算机里面正常运行我们的–操作时,都是由CPU去执行的,CPU是一个能够进行执行我们对应的,可以理解成CPU呢,它是一个执行我们计算的一个叫做唯一的场所,而我们定义的一个tickets,是一个全局变量,而这个变量呢,他在哪里?它是在我们所对应的叫做称之为内存中的,那么其中对我们来讲呢,在内存当中,在内存当中呢那么其中对应的tickets变量,它里面tidckets ,而tickets里面保存的是多少呢,这里我们可以称之它们的值是100。那么其中对我们来讲呢,我们可以知道CPU要做计算,那么他有一个重要的概念,他不能直接在你的内存里面对数据做–操作,它一定要进行3个基本工作:1.取数据,它要把数据直接先取到自己的内部寄存器里,取到寄存器里的值就是我们对应的100,取进来之后呢,2.对内部寄存器的值做–操作,做–操作这个值随即就会把我们这个对应的值100,减成99,这里是第二步–操作;3.–操作完成之后,把计算完成的结果,再写回我们对应的内存,将原先内存值修改成我们目标的99。同学们,这个工作就可以称之为叫做,我们所对应的,完成了一个写入。
》所以对我们来讲呢,我们tickets–操作呢,**1.load tickets to reg寄存器里;2.reg–,对寄存器里的值–操作 ;3.write reg to tickets,把寄存器里的值写回到内存,将原先的值改为目标值。**它是有3条语句要被执行的。其中呢,我们线程未来要执行的时候,执行对应的代码时,是怎么执行的呢,线程在执行你对应的代码时,执行第一条语句、第二条、第三条从上往下,当然你上面代码有很多,按照顺序执行非常非常多的代码,必须得讲我们说的那3条语句执行完,才能说完成了对tickets–的操作。
》好了同学们,那么我们想要告诉大家的是,**在这3个语句的任何地方,线程都有可能会被切换走。我相信这个对大家不难理解。我们下面再来谈第二个问题,对我们同学来讲呢,还有一个什么概念再来给大家说一下,线程在执行的时候,在任何一条语句都有可能被切走,这是其一。
》其二,我们曾经讲线程切换的时候,讲过一句话,叫做
CPU内的寄存器是被所有的执行流共享的,但是寄存器里面的数据是属于当前执行流的上下文数据。也就是寄存器它呢,可以被所有的线程共享,因为,本来就是我要运行之前,我要把我的数据放在寄存器上,那么寄存器里面的数据,它和寄存器是两码事,寄存器就是一套,它的数据呢放到我们寄存器上,它寄存器里面的数据是属于当前执行流的上下文数据。所以这里曾经还讲过一个话题, 叫做线程被切换的时候,需要保存上下文的;线程被唤回的时候需要被恢复上下文。所以对我们来讲,所谓的保存上下文是什么呢?所谓保存上下文其实是 保存寄存器里面的数据,恢复的时候就把你的数据再恢复到我们的寄存器,它呢保存在哪儿呢?你可以理解成保存到PCB里。
》下面呢,我们就结合上面说的一和二我们两个所谓的基本共识,然后我们再来讨论刚刚tickets–操作的过程。那同学们假设,在物理内存的某个空间存储着我们的tickets,它的值默认的是100,然后紧接着CPU内有一个寄存器,我们可以称之为reg,然后呢CPU在对应我们的执行的时候呢,它是要把tickets的值从内存中直接给我们load到寄存器里。假设现在有一个线程,他要准备抢了,它将100数据load到寄存器里面,此时reg的值是100,然后它再做–操作,把它直接变成99,变成99之后其中对我们来讲,我们的线程正准备做第三步工作写回内存的时候,也就是准备把数据写到内存的时候,不好意思当前线程要被从CPU上拿下来,拿下来就拿呗,此时这个线程直接就把寄存器里的值放到了线程的上下文里,也就是线程把99这个数据带走了。然后呢,此时线程B来了,线程B也要做抢票,它也要把内存里的100load到寄存器reg里,然后再寄存器reg里做–操作,然后再把99写回到内存里,此时内存的值变为99了。但是线程B的优先级,调度的时间都比较多,它在执行完一次抢票时没有被切走,而是回来继续抢票,所以线程B呢就疯狂的去执行抢票动作,没人打扰他,最后把票抢到了只剩下10张了,相当于线程B抢了90张,当线程B继续执行抢票的逻辑,当执行到已经将寄存器reg里的10减到9的时候,准备写回内存的时候,线程B被切走了。线程B走就走呗,然后把49保存在自己的上下文里了。后面操作系统把一开始的线程A 恢复上去,此时发现线程A它刚刚是在哪儿执行退出的呢,它刚刚是把100读进寄存器了,然后已经减到了99,下一步就是写回内存的时候被切走,所以现在要从将99写回内存的动作,一旦现在写回内存,就直接把线程B把票抢到只剩下10的情况,现在又变成了99。那么此时发生这样的情况,我们就发生了tickets数据的不一致问题。本来只有100张票,现在被你线程B抢了90多张,现在A一回来写回成99张,线程后续接着抢,那么是不是其中就让线程多抢了很多票。
》所以,因为线程切换而导致的数据在进程操作的时候,出现了不一致的问题,这就称之为多线程之间并发访问我们的临界资源,而产生数据不一致的问题。刚刚说的例子是一个非常极端的例子,怎么可能让一个线程1张都没抢,让其他线程抢了90多张,那是不太可能的,只是把例子极端化,同学们会比较好理解。所以我们该怎么去解决这个问题呢?很简单,我们要保证的就是,我们要看到其中我们在抢票的时候呢,tickets在–操作是3条命令,在其中任何执行地方都有可能被切走,所以此时出现因为时序的问题,导致出现数据不一致的情况也很正常。那么我们要解决这个问题,怎么解决呢?很简单,我们只要保证–操作的行为是 “原子性”的就可以了。也就是说我们只要保证是原子的。
》拿什么叫做原子的呢?一件事情要么做了,要么没做。不存在load到寄存器的这种中间状态,不存在在寄存器–一下,不存在最后还要写回。也就是说呢我们把它做–操作,能不能把它通过某种方式一次就做完了。不要出现什么中间过程,这就是原子性。那么同学们,对我们来讲呢,一件事情要么不做,要做就做完的特性就是原子性。当然原子性的概念其实挺复杂的,我们最基本的理解就是要么不做,要么就做完。当然对我们今天而言,我们不在乎这个票谁抢的。根本原因还有一点,在我正在执行着–操需要的3条指令过程中,如果我有一种可能性,让我的任何线程在执行这3条指令的时候,在我执行这3条指令期间,如果我不执行这里面的3条指令,那么我就不执行,如果我执行完那也挺好,但执行期间我不想被打扰。如果我们在执行这3条指令,不想被打扰,那么此时我们怎么做呢?那么此时我们可以给我们的临界区,访问tickets的临界区对它进行加锁。如果你今天想要访问临界区不想被打扰,就使用加锁机制,而加锁不想被打扰,我们就有了第三个概念叫做互斥,什么叫做互斥呢?
》互斥:当我们访问某种资源的时候,任何时刻都只有一个执行流在进行访问,这个就叫做互斥特性。
》也就是说,任何时候都只有一个进程在访问。那有人说,什么叫做任何时候只有一个人访问呢?就比如说,我刚刚的线程A,它正在访问这个100,还没写回去呢,这都叫做正在访问。还没写回去,即便我被切走了,你这个线程B也不能跟我抢,因为我还没访问完呢,这就叫做我们的互斥特性。那么,我们现在呢如你所说确实存在这样的情况,这是其一。其二呢,也确实,刚刚的tickets–不是原子的,更别谈你还有一个if()判断tickets > 0;你看看有没有可能,举个例子,当前内存里的值tickets为1,值为1的话,对我们来讲,多个线程同时进行读取它这个为1的值呢?线程A把 1 load到寄存器里,然后被切走了,那它被切走了怎么办,其实代码里面出了tickets–操作有问题,其实if()判断tickets > 0也有问题,你以为做判断就没有问题了吗。我们给大家再交代清楚一点。
》我们来看看我们说的判断,假设现在内存当中tickets的值为1, 当线程A、B在执行的时候,线程A呢,它要执行判断了,判断tickets > 0,请问判断一个变量需要CPU参与吗?肯定要,为什么呢, 因为,给大家多说一下,其实我们计算机做 > 大于 0判断,实际上它是把它做成了一个,它是一种逻辑运算没错,但是呢,像这种操作也是转成数学运算。比如说,ticekts - 0,变成了tickets + (-0),然后最后去判断,最后的值是否为真就可以了。所有计算机的,CPU里的操作都会被转化成叫做加法操作或者是移位操作,比如说我们一般在做加法的时候,就是正常的二进制加法,减法呢也会被转化成加法,比如说,a-b,实际上会转化成a + (-b),也是做的加法,像我们的乘法也可以理解成,比如说你乘的时候,a * b,对我们来讲a * b呢,把这个b看成2的几次方,比如说是乘以5就可以看成的2^2+1。比如说你是35,就会看成3 * (2 ^2 + 1) = 3 * 2 ^ 2 + 3 1,CPU里面呢,加法和减法好理解,加法呢就是加法,减法就变成加法。3 * 2^2是什么呢,2的几次方在计算机是什么操作?就是左移两位<<,左移之后呢再加上3嘛。这个时候就把乘法转化成了 移位操作和加法操作。除法也是类似的道理,比如说5/3,就会看成5 / (2 ^1),这也被转化成 移位操作和加法操作。所以呢,计算机里的所有操作都会转化成加法或者是左移右移的加法操作。这是CPU内部实现计算的重要逻辑。
》所以呢其中判断tickets > 0其实说白了。我们也可以想象成减法,说白了就是tickets - 0,然后判断结果最终是否为真。所以我想说的就是
if判断也是需要CPU的。**不要就觉得x * y等等才需要CPU参与。这就是计算机组成原理知识,这就是学可以学科之间关联。我们有时候看不懂东西,其实说到底,每个东西,都很难,不管是操作系统还是未来学的网络,还是什么编译原理。尤其是计算机的三大剑客,计算机组成、操作系统、计算机网络这是计算机学科最重要的3个东西。
》其实就想告诉大家一个道理,if判断里面有各种条件设定判断的时候,也是要CPU参与的。要CPU参与是不是就要把它读到寄存器里,所以线程A把内存里tickets为 1的值读到寄存器里,现在就要开始判断了,确认是1,线程A就直接进入到if里面的代码,它准备进来,正准备执行tickets–操作,线程A正准备继续向后执行的时候,不好意思线程A怎么样了呢,线程A被切走了,被切走之后,线程A拿着自己的上下文,然后操作系统调度线程B,线程B进来说诶,我也要抢票,那我就去看一下这个票,所以它也要执行从我们内存读到CPU里面,当他做这个执行的时候,内存里的票数到我们的寄存器时候,这个票数是1, 同学们,票数读到的就是1的话,它也去做判断,票数还是 > 0呀,所以线程B也可能进去执行–操作,可是两个线程同时进来,线程A执行–,线程B执行–,最后就可能把票数减成了负数。这种情况也是存在。同学们,所以对我们来讲2个线程同时进入执行流,可是票数只有一张,怎么可能出现这样的情况呢,根本原因在于这里的判断也不是原子的。
在这里插入图片描述
在这里插入图片描述

》我们把我们的准备工作也准备完了,下面呢,我们就尝试着,第一,你说的挺多的,可是你刚刚给我运行的时候,我也没有见到最后抢票抢成负数呀,更没有说票越抢越多的情况呀 。所以我们要做两个工作,第一个是复现问题,让同学们看到抢到负数的情况。所以接下来是复现问题,然后是解决问题。

//__thread int global_value = 100;
//int global_value = 100;

/*void *startRoutine(void *args)
{
    pthread_detach(pthread_self());
    cout << "线程分离....." << endl;
    while (true)
    {
        //临界区,不是所有的线程代码都是临界区
        cout << "thread " << pthread_self() << " global_value: "
             << global_value << " &global_value: " << &global_value
             << " Inc: " << global_value++ << " lwp: " << ::syscall(SYS_gettid)<<endl;
        sleep(1);
        break;
    }
    退出进程,任何一个线程调用exit,都表示整个进程退出
    exit(1);
    pthread_exit()
}*/

// int 票数计数器
// 临界资源
int tickets = 10000; // 临界资源,可能会因为共同访问,可能会造成数据不一致问题。
pthread_mutex_t mutex;

void *getTickets(void *args)
{
    const char *name = static_cast<const char *>(args);

    while (true)
    {
        // 临界区,只要对临界区加锁,而且加锁的粒度约细越好
        // 加锁的本质是让线程执行临界区代码串行化
        // 加锁是一套规范,通过临界区对临界资源进行访问的时候,要加就都要加
        // 锁保护的是临界区, 任何线程执行临界区代码访问临界资源,都必须先申请锁,前提是都必须先看到锁!
        // 这把锁,本身不就也是临界资源吗?锁的设计者早就想到了
        // pthread_mutex_lock: 竞争和申请锁的过程,就是原子的!
        // 难度在加锁的临界区里面,就没有线程切换了吗????
        pthread_mutex_lock(&mutex);
        if (tickets > 0)
        {
            usleep(1000);
            cout << name << " 抢到了票, 票的编号: " << tickets << endl;
            tickets--;
            pthread_mutex_unlock(&mutex);

            //other code
            usleep(123); //模拟其他业务逻辑的执行
        }
        else
        {
            // 票抢到几张,就算没有了呢?0
            cout << name << "] 已经放弃抢票了,因为没有了..." << endl;
            pthread_mutex_unlock(&mutex);
            break;
        }
    }

    return nullptr;
}


int main()
{
    pthread_mutex_init(&mutex, nullptr);
    pthread_t tid1;
    pthread_t tid2;
    pthread_t tid3;
    pthread_t tid4;
    pthread_create(&tid1, nullptr, getTickets, (void *)"thread 1");
    pthread_create(&tid2, nullptr, getTickets, (void *)"thread 2");
    pthread_create(&tid3, nullptr, getTickets, (void *)"thread 3");
    pthread_create(&tid4, nullptr, getTickets, (void *)"thread 4");
}

✳️复现出错现象:
上面,我们把各种原因也分析完了,下面呢,我们就尝试着去解决它,可是在解决这个问题之前呢,我们连这个问题都没有发现, 也就是说运行也没有出现我们上面的错误现象呀。那怎么办呢,我们就要尝试着给大家复现这个问题。
》那么我们打算怎么去复现这个问题呢,首先,我们刚刚说的所有并发场景,核心点在哪里呢,核心点在于我们叫做,线程切换。因为今天我们已经知道,我们前面有过的tickets这样的全局变量呢,实际上呢就是一个临界资源。多线程在访问的时候呢,出现一些访问的问题呢,也很正常。但现在的问题是,没有出线这样的问题,但是我们刚刚分析的所有情况,全部都是线程切换引起的,换句话说,它没有出现问题,只能证明我们今天线程切换并不怎么频繁。那我们要复现这个问题怎么办呢?我们可以尝试着让我们呢线程切换的工作变得更频繁一些。另外如果线程较多的话,在操作系统内,在调度的时候,也会正常的去发现,你可以理解成叫做,就是线程一多,每个线程我都想让他们切换一下 ,这个时候就可能把这个问题复现出来。所以首先想出来线程3个感觉挺多的,够了 。但怎么尽可能的让线程进行切换呢?
》这里就要再谈一个叫做线程切换的场景—线程,包括进程,因为线程本身就是轻量级进程嘛,它是在什么场景下进行切换的呢,我曾经讲过,只是提嘴一说,没有正式去会说。那么同学们,曾经我们在讲信号的时候说过,从内核态返回用户态时,我们要进行对信号做检测,但是同学们可不仅仅是在那个时候对信号做检测, 还有一个就是时间片到了,当前这个线程就要被切走,这是很正常的。第二个叫做,我们的线程会在内核返回到用户态做检测。也就是线程也会在内核,就是它状态或者轻量级进程,它的调度呢会在内核态返回的时候做检测。这个你也能理解嘛,当我在用户态的时候,我没办法做检测,只能是内核态做检测,在内核态做检测的时候,那么我为什么就进入内核呢,一定是有更重要的事情,比如说是系统调用。一定是把系统调用调完,回去的时候再做我们对应的线程的,比如说,我怎么知道你的时间片到了,我怎么知道你这个线程要被挂起了。所以线程会在内核态返回到用户态时做检测。并且我们尽可能多的创造更多的让线程阻塞的场景。也就是说,只要我们能做到,第一,让操作系统尽可能让每个线程不断的创造出更多的陷入内核的场景。它只要陷入内核,就一定会返回。第二,创造出让更多的线程会阻塞的场景。因为阻塞就会出让CPU,只要它阻塞,出让CPU,就意味着什么呢,就意味着 其他线程会被放到CPU上去,其他线程也会被阻塞,那其中,我们就不断的有线程被阻塞,被挂起,被阻塞,被挂起… 伴随着一定是大量的进程切换。那怎么做到呢?
》所以可以再加一个usleep()函数,以微秒级别的,不是秒级别的。一个线程它被休眠了,它还会不会是R状态呢。不会,它一定是被阻塞,或者某种意义上的挂起。所以它直接在某个,比如说我们的,某些地方等待。时间到了,再把这个线程唤醒。所以同学们对我们来讲呢,我们可以让它休眠上一小会儿。我们再来试试让线程跑起来去抢票。我们发现还是没看到现象。现象确实难复现,我们在其他某些位置加上usleep()后确实现象出来了。我们是能够证明我们的代码当中是可能会存在问题的。所以我们把问题复现出来,说到底其实是因为,在线程切换的场景下呢,我们就可以直接出现对临界资源的访问的问题。

✳️解决办法
没关系我们可以对临界区来加锁。什么意思呢?加锁的意思就是说,我们要保证在,访问票的时候,说白了就是在执行ticket–所需要的那3条语句的时候,必须不想被打扰,我要互斥式访问。也就是说,我在执行这3条语句的时候,你不能打断我,即便你打断我了,那么你也不能去访问对应的,我们称之为票。比如我正在访问,你就不能去访问。这叫什么,这叫做互斥访问。只要我能互斥访问,那么此时每一个人就去,在进行我们对应的执行tickets–这条语句之前,就得先进行加锁。所以怎么办呢,我们正式来认识一下互斥锁。
》互斥锁呢,是我们在多线程场景当中,最常见的一把锁,没有之一,最常见。互斥锁呢,它的一个用法呢非常简单。在我们的Linux当中呢,pthread库也原生的帮我们实现了我们的互斥锁。这个互斥锁的名字叫做 “pthread_mutex_init()”。其中互斥锁叫做mutex,其实这个名字你可以随便起,它是被这个ptread_mutex_t类型定义出来的。所以只要你定义一把互斥锁,那么这个互斥锁就可以实现对某资源的进行互斥访问。
》把互斥锁定义出来,有两种方式,第一种方式叫做定义全局的互斥锁,定义全局互斥锁呢,所有线程都能访问,但是对我们来讲,每一个人在访问临界资源之前都得加锁,这就叫做互斥锁。我们一会儿,先把互斥锁的应用讲完,然后再谈它里面的细节。
》那么其中你的互斥锁是静态的或是全局的,那么这把互斥锁呢,同学们,我们这把锁呢,就可以用PTHREAD_MUTEX_INITIALIZER,这么一个宏来初始化它。也就是对我们来讲呢,这个互斥锁如果是静态的或者全局的,你用它这个宏来做初始化就可以。如果它是一个局部的互斥锁,那么你此时就使用pthread_mutex_init()函数来对锁进行初始化,把锁的地址传进来,锁的属性我们设置为空nullptr就行。当你未来不用这个锁了,我们ptrhead_destroy()函数叫做释放这个锁。好,同学们,这就是初始化和释放锁这样的一个概念。那么其中呢,我们一开始不谈这个全局的,后面写的时候慢慢用这个全局的。我们今天初始化锁,把该用的函数都用起来,锁的属性我们也不考虑。如果我们能把锁初始化和释放了,那怎么用呢?
》就要谈到”ptrhead_mutex_lock()“函数,他就是加锁,把你创建的对应的锁传进来,此时呢就是相当于调用该函数的线程就会自动实现加锁,这种加锁是阻塞式加锁。那什么叫做阻塞式加锁呢,如果今天这把锁你在申请时,别人正在用,你就不能用,你呢就被阻塞住或者放在等待队列中等别人用完,你才用这把锁。所以它是阻塞式申请。
》还有一种锁呢pthread_mutex_trylock()函数,叫做非阻塞式申请,如果你去申请这把锁,锁被别人占着,怎么办呢,锁被别人占了,没关系,此时你就立马返回就行。如果你此时申请一把锁没被别人占,你再可以申请到锁。
》我们能把锁申请,那么最终也要对我们的锁进行解锁。也就是能加锁,也要能解锁。你不解锁,最终会出现一些问题。稍后再研究特殊情况。
》锁怎用呢,今天我想给大家这样子去写。首先我们先写一个最常见的, 锁呢,我给你定义成全局的。我们先用pthread_mutex_init()函数来初始化锁。我们用pthread_mutex_t类型来定义一把锁,即pthread_mutex_t mutex;当我有了这把锁之后呢,紧接着在我用这把锁之前,我要先对这个锁进行初始化。怎么初始化呢,那就要用到pthread_mutex_init()函数,第一个你要对那把锁初始化呢,那就将刚刚定义的mutex取地址传进去。第二个,这把锁的属性是什么呢,设置为空nullptr。就可以了,这叫做预用这把锁之前先初始化。然后呢,我怎么知道我用完了这把锁?很简单,你用完了一定是你这一批线程退出了。全部退出了,那么此时呢,就调用pthread_mutex_destroy()函数,直接释放这把锁.那么其中对我们来讲,这就是申请锁和释放锁。
》那么在我们线程被创建起来之后,一定是锁已经被触发了,所以我们就要对我们所谓的临界区加锁。注意,我们加锁可以这么加,怎么加呢,pthread_mutex_lock(&mutex);然后解锁就是pthread_mutex_unlock(&mutex)。同学们,因为我是在临界区当中,加锁难道可以在这个位置加吗?直接我在对应抢票逻辑当中,直接在上面做加锁,加完锁之后再释放锁。那么,肯定可以,但你不合理。为什么呢?我们编译一下代码运行,当我们实际抢票的时候,你这样加锁是可以的,但是就变成没有人跟你抢了。没有人能抢得过你,因为你一旦成功申请到了锁,申请到了锁之后呢,抢票的线程都是你这一个线程。而且同学们,这样只有一个线程抢的话,那么抢票的效率肯定也会降低。我们不能这么去加锁,这样子加锁的粒度太粗了。而且你对eles里面的代码加什么锁,他又不是访问临界i资源,whine训话语句也没访问临界资源,if()条件判断也不是访问临界资源不属于临界区。只有哪一块代码是真正的临界区呢。
》同学们记住了,我们**加锁的时候,只要对临界区加锁,而且加锁的粒度越细越好。**相当于锁加的时候,它是串型的,你一旦把串型的锁加上了,那么执行这段代码的只有你一个人。如果你把锁加的太多了,或者一个临界区代码太多了, 粒度太粗,最后会导致,**加锁的本质是让所有的线程串型化去执行临界区。**加锁的本质是让我们所对应的我们执行临界区的线程串型化。本来多线程是并发的,一旦加锁则执行这一块代码则是串型化的。那么欢句话说,串型的代码越少,串型的场景越低, 那么串型化耗费的时间就越少,那么并发度就越好。所以呢,我们不能对所有的代码都加锁,只要对临界区加锁,而且临界区越小越好,自己在写的时候一定要注意。所以怎么办呢,我们把加锁的代码放在while循环里,换句话说,你抢完一张票,一个线程抢完一张票,下一次的时候, 你就应该怎么办呢,下一次回过头你就应该再去抢票。但是你每次抢票也得重新去申请锁,所以我们给它在if()上面加锁。最后加完锁,抢完票之后,我们要解锁,解锁能不能就在else下面呢?
》换句话说呢,我把加锁的粒度呢调细了,不要再while循环加,而是在里面去加锁,这样有没有问题呢?我们再编译代码并运行,此时我们发现他最后卡住了 ,怎么卡住了呢,为什么我们现在线程执行的时候卡死了呢?不是说要执行线程抢票吗,那么其中原因在哪里呢?原因在,当我们对应的线程抢这个票时,把票正常抢完之后,释放这个锁,while循环再继续进行抢票,这是正常的情况,没有问题。但是呢对我们同学来讲,这种情况呢,如果我们抢到的票,如果已经不满足条件了,它此时已经打印没有票了,然后break出循环。一旦break,那么后面就没有执行这个解锁。没有执行解锁,那么此时一个线程他自己break退出了,比如说线程4执行完走了,但是你当前的线程1、2、3这三个货还在静静的等待你释放这个锁呢。它们在等这个锁释放的时候,对不起当前对应的线程呢竞争这个锁的时候呢,它想争,可是你线程4退出的时候,没有释放这个锁,没有释放这个锁,你最后跑出去,剩下3个线程一直在这个mutex上休眠等待着你这个锁释放,可是你就是不释放。你就是不释放怎么办呢?你不释放没办法,此时这3个线程不退出,不退出怎么办,那么我们主线程调用的join就阻塞式的等待新线程退出。所以最终表现为票抢完了,但是卡在那里不动了。所以我们的锁释放一定要正确释放。要不然会造成刚刚最后卡在那里不动的现象,刚刚那种现象是一种 “死锁”的现象,我们后面说。所以我们在if()里面进行解锁。然后我们还得在else语句里面,且break之前也要有释放锁的代码。
》像上面最后的加锁和解锁那样的话,那么基本能解决上面的问题了。我们继续编译代码并运行,发现线程4疯狂抢,其实也很正常,因为唤醒一个线程的成本很高,后面又看到是2号线程疯狂抢。大家都是一批批的去抢,后面我们可以解决一下。但是我们不会出现数据不一致的问题了,因为我们加了互斥,那么现在也确实按照我们的预期。但是你这次能明显感觉到比之前慢了好多!
》慢是应该的吗?答案是:应该的。因为当前你这次在抢票的时候,首先你们每一个线程都竞争锁的时候,申请锁本身就是要花时间,另外你没抢到锁,那么你被挂起或者阻塞了,那么你当前的线程呢,就只能在合适的时候被唤醒。那么唤醒的时候么,这个过程来回进程被运行,。放入等待队列里,它的时间很长,所以你的抢票过程比较久。
》下面给大家解决一下,同学的问题。 第一个问题,记住了,加锁只要对临界区加锁,有同学会说,我现在是抢票函数1和抢票函数2,我对于其中一个函数不做加锁,当然解锁也不用做了。相当于呢,我们现在有两套抢票函数,一套是加锁的,一套是没加锁的。我们让两个线程去抢加锁的抢票函数,另外两个抢没加锁的抢票函数。那这样的话,你所谓要给我保证互斥性,你也做不到。换句话说,有同学会这么去想,相当于什么呢,有时候呢,我们几份代码都是访问临界资源的,你说要保证互斥,好,你们保证互斥吧,我不保证互斥,我不给你加锁,我不给你加锁的话,那么其中你怎么保证互斥性呢?这个逻辑是不对的,记住了,**加锁是一套规范,**如果我们对临界资源访问,**通过临界区对临界资源访问的时候,要加就都要加。**要加锁的话都要给我们的临界区加锁,你不能一部分加锁,一部分不加锁,这是有问题的。所以,你不能说你自己,如果你个一部分加了锁,另一部分临界区你不加,那么就叫做你写的代码有bug!所以记住了,加锁的过程,临界区进行保护的时候,我们要访问保护,你就必须所有临界区保护,你不能只给一部分加,一部分不加。换句话说,你要对临界资源进行保护,凡是访问临界资源的 所有人都必须得申请锁,释放锁 。如果你不做这个工作,那么你的代码就是有bug。你不能因为你的代码写错了,你告诉我我的代码怎么没有保证互斥性。
》第二个我们再给=大家谈一谈关于互斥锁这里,我想给大家再加一点。那么,我们用我们对应的这个互斥锁,然后我们进行对临界资源进行保护,所以我们发现,锁保护的是临界区,锁保护的是临界区的话呢,对我们来讲,每一个人访问临界区都必须加锁,那么临界区访问完,必须释放锁,那么这个是没有问题的。但是,**锁保护的是临界区,**换而言之,**任何线程执行临界区代码,访问我们临界资源,**任何线程想要执行临界区代码,要访问我们的临界资源,**它都必须先申请锁。**是不是这个道理,你们都必须得先申请锁。而我们每个线程在访问临界区都必须得先申请锁,前提是都必须先看到锁!
》如果我们,前提每个线程都看到了这一把锁,那么我的问题是,**这把锁它本身不就也就是临界资源吗?那谁来保护它呢?所以对我们来讲,这把锁你就是临界资源, 你保护别人,谁来保护你。再加锁,不就成了鸡生蛋,蛋生鸡的问题吗。所以不现实。我们能想到的,锁的设计者早就想到了。
》所以今天就告诉大家,当我们在进行我们的
申请锁调用pthread_mutex_lock()的时候,竞争和申请锁的过程就是原子的!**换而言之,申请锁的过程不会有人来中断你,你要么能申请到,要么你就申请不到,不会有中间状态,所以你线程怎么切换都不会影响锁的申请。
》难道在加锁的临界区里面,就没有切换了吗??是,你现在加锁确实解决了多个线程访问临界资源,数据不一致的问题,但是你语句不还是有if里面有一大堆,那么我自己在我加锁的那个区间里,我这个线程会不会还是被切换呢?切换有影响吗?或者难道我们是不切换了吗?这些我们下面会说。
》有同学自己写了抢票的代码,发现一个问题,怎么老师一个线程在疯狂的抢呢,其实其他线程也会抢,只不过呢是因为有些线程先运行,会什么会出现这样的现象呢,其实原因是这样子的,因为你的线程谁先运行不确定,一般比如线程4先跑,虽然最后被创建,但是它先跑了,这个没办法,这个是由调度器决定的,当我们线程4先申请的时候,它申请到锁了,它申请到锁就在自己的时间片里面就直接去执行抢票,其他线程呢,它申请不到会被挂起,它被挂起或者被阻塞,从阻塞到唤醒是要花时间的,而我们的某一个线程,比如线程4它吧票抢完从新申请锁,它的成本要比其他线程被唤醒的成本要低得多,所以这个线程继续能申请到锁。其实呢你想看到一个其他线程也在抢锁的现象,你可以加点代码,因为我们释放锁后基本没有什么要执行代码,实际上你抢完票还有一大堆代码要去执行,这些非临界区代码我们没写而已。比如说你抢到票呢释放了锁,你要把票投递到客户的邮箱里,然后你还要汇总一下数据发短信什么的,所以对我们来讲其实你后面还有代码,只不过你没写罢了,所以导致这个线程锁一释放,立马就过去竞争锁了。 所以可用usleep()来模拟其他业务逻辑。

====================================================================================================

pthread_mutex_init()函数:

#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
              const pthread_mutexattr_t *restrict attr);
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

函数介绍:
其中互斥锁叫做mutex,其实这个名字你可以随便起,它是被这个ptread_mutex_t类型定义出来的。所以只要你定义一把互斥锁,那么这个互斥锁就可以实现对某资源的进行互斥访问。
其中你的互斥锁是静态的或是全局的,那么这把互斥锁呢,同学们,我们这把锁呢,就可以用PTHREAD_MUTEX_INITIALIZER,这么一个宏来初始化它。
也就是对我们来讲呢,这个互斥锁如果是静态的或者全局的,你用它这个宏来做初始化就可以。如果它是一个局部的互斥锁,那么你此时就使用pthread_mutex_init()函数来对锁进行初始化,
把锁的地址传进来,锁的属性我们设置为空nullptr就行。当你未来不用这个锁了,我们ptrhead_destroy()函数叫做释放这个锁。

pthread_mutex_lock()函数:
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

pthread_mutex_lock()函数介绍:
他就是加锁,把你创建的对应的锁传进来,此时呢就是相当于调用该函数的线程就会自动实现加锁,这种加锁是阻塞式加锁。那什么叫做阻塞式加锁呢,如果今天这把锁你在申请时,
别人正在用,你就不能用,你呢就被阻塞住或者放在等待队列中等别人用完,你才用这把锁。所以它是阻塞式申请。

pthread_mutex_trylock()函数介绍:
还有一种锁呢,叫做非阻塞式申请,如果你去申请这把锁,锁被别人占着,怎么办呢,锁被别人占了,没关系,此时你就立马返回就行。如果你此时申请一把锁没被别人占,你再可以申请到锁。

====================================================================================================

int tickets = 10000; // 临界资源,可能会因为共同访问,可能会造成数据不一致问题。
pthread_mutex_t mutex;----➡️定义出一把锁

void *getTickets(void *args)
{
    const char *name = static_cast<const char *>(args);
    
	/* pthread_mutex_lock(&mutex);*/------❌加锁的粒度太粗,不能这样加锁
    while (true)
    {
        // 临界区,只要对临界区加锁,而且加锁的粒度约细越好
        // 加锁的本质是让线程执行临界区代码串行化
        // 加锁是一套规范,通过临界区对临界资源进行访问的时候,要加就都要加
        // 锁保护的是临界区, 任何线程执行临界区代码访问临界资源,都必须先申请锁,前提是都必须先看到锁!
        // 这把锁,本身不就也是临界资源吗?锁的设计者早就想到了
        // pthread_mutex_lock: 竞争和申请锁的过程,就是原子的!
        // 难度在加锁的临界区里面,就没有线程切换了吗????
        pthread_mutex_lock(&mutex);-----➡️申请锁
        if (tickets > 0)
        {
            usleep(1000);
            cout << name << " 抢到了票, 票的编号: " << tickets << endl;
            tickets--;
            pthread_mutex_unlock(&mutex);------➡️解锁

            //other code
            usleep(123); //模拟其他业务逻辑的执行
        }
        else
        {
            // 票抢到几张,就算没有了呢?0
            cout << name << "] 已经放弃抢票了,因为没有了..." << endl;
            pthread_mutex_unlock(&mutex);------➡️解锁
            break;
        }
        
       /*pthread_mutex_unlock(&mutex);*/----❌会造成死锁的现象,假如申请锁的线程最后抢完了执行else语句中的break就跳出去,没能够释放锁,其他线程会一直卡在申请锁那里。
    }
	
    return nullptr;
}
int main()
{
    pthread_mutex_init(&mutex, nullptr);----➡️初始化一把锁
    pthread_t tid1;
    pthread_t tid2;
    pthread_t tid3;
    pthread_t tid4;
    pthread_create(&tid1, nullptr, getTickets, (void *)"thread 1");
    pthread_create(&tid2, nullptr, getTickets, (void *)"thread 2");
    pthread_create(&tid3, nullptr, getTickets, (void *)"thread 3");
    pthread_create(&tid4, nullptr, getTickets, (void *)"thread 4");

	 int n = pthread_join(tid1, nullptr);
	 int n = pthread_join(tid2, nullptr);
	 int n = pthread_join(tid3, nullptr);
	 int n = pthread_join(tid4, nullptr);
	 pthread_mutex_destroy(&mutex);-------➡️锁用完了就释放掉。
}

1.我在临界资源对应的临界区中加锁了,就不是多行代码了吗?如果还是多行代码,可以被切换吗?加锁----不会被切换吗??
》同学们按照我们之前对应的一个理解呢,在多线程执行的情况,我们呢,只要加了锁,数据却是没问题,但会不会发生线程切换呢?如果发生了线程切换,那为什么我们之前没有出现,你所说的,因为线程切换而导致变量同步修改或者错误修改的这种情况呢?这是其一。
》其二呢,2. 还有一些接口要给大家介绍一下,其他接口不是重点,但是呢还有一些,比如说我们在创建锁的时候,用全局变量或静态变量的锁,那么对我们来讲,我们该怎么去认识呢。其中在这里把接口在明细一下。

我们先创建一个mythread.cc、Makefile 。然后开始编写代码。我们先定义全局变量tickets100张票,即int tickets = 100;我们在紧接着用pthread_t类型定义4个线程,即pthread_t t1,t2,t3,t4;紧接着呢,我们调用pthread_create()去创建线程,即pthread_create(&t1, nullptr, startRoutine, (void*)“thread 1”)。然后主线程肯定还要调用pthread_join等待新线程退出,退出结果我们不要就设为空nullptr,即pthread_join(t1, nullptr);然后我们开始写startRoutine()回调函数。

✳️定义锁的话呢有很多种方式,我们这里先用全局的锁,pthread_mutex_t mutex;你想用这个锁,除了我们上面写过用pthread_mutex_init()函数来初始化我们这个锁,你也可以用静态分配的方式即用宏:PTHREAD_MUTEX_INITIALIZER来初始化(静态的,即static修饰的锁也可以用宏来初始化)。即pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

我们对锁进行初始化好后,就要对临界区进行加锁了,我们在if()判断的语句上方加锁,加锁自然就要用到pthread_mutex_lock()函数。既然有加锁,那必然有解锁,我们在tickets–语句的下面加上解锁语句,并且在else语句里面也要有解锁语句,避免发生死锁的情况。解锁就要用到pthread_mutex_unlock()函数。我们上节课也看到了,若我们没在else里面加解锁语句的话,那么你拿锁的线程break出去之后,没有释放锁,那么这个锁无法被其他线程申请到,不会被其他线程申请到,那么pthread_mutex_lock()默认会阻塞我们的线程,也就是说你线程申请不到锁就会阻塞线程。
》然后我们编译代码并运行,可以看到线程就开始抢票了,自己在做实验的时候,你会发现一个问题,当我们实际上抢票的时候, 有可能有很长时间只有一个线程在抢,这个是很正常的,因为正常情况下,你把票抢了,其实在正常的逻辑里你还要去处理其他的业务逻辑的。所以我们今天抢票的逻辑当中呢,其实一定是有很多很多的,正常情况下,你把票抢到了,你是不是还要给用户同步信息, 然后用户去确认再去下单等等,所以还有一大堆事情,也就是一个线程把事做完了,不是抢到了就完了,后面还有事情要处理的,只不过我们没有业务,那我们就调用usleep()来模拟业务场景。加上这个usleep()之后呢就可以看到多个线程都在抢了,而不是单纯的一批票都是一个线程抢的。
》我们还有一个比较关键的概念就是:互斥锁在使用的时候,除了你给我讲的这种方案,我呢还有其他方案。还有什么方案呢?我们不定义全局的锁了,我们定义在局部,我们在main()函数里main定义一把锁,则我们必须得用pthread_mutex_init()函数去初始化;但是你若局部的锁但是由我们static修饰,即静态的,也是能用我们的PTHREAD_MUTEX_INITIALIZER宏来初始化。即staict pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;线程在创建时,phread_create()函数呢,最后一个参数是可以传进去一个参数的。可以传进去,那我今天就想传你这把锁怎么办呢?我们先用C的方式写一写,有很多方式。最简单的第一种方式,一定要给同学们讲讲最原生的,如果不讲原生的很多东西其实我们很难理解。
》第一种方式:我们可以直接&mutex,即(void*)&mutex,你不是一个对应的锁吗,锁也是一种类型,即pthread_mutex_t,它也是一种类型,那么是类型,你锁也就有对应的地址。那么接下来要讨论的一个问题就是,那么锁能够通过(void*)&mutex参数传到我们对应的每一个线程。所以拿到了之后呢我们startRoutine()函数内的const char* name = static_cast<const char*>(args),name不再是线程名字了,也就name改成pthread_mutex_t* mutex_p = static_cast<pthread_mutex_t*>(args);我们也就拿到了一把锁;然后加锁函数pthread_mutex_lock()参数改为mutex_p就行了,即pthread_mutex_lock(mutex_p);同样解锁pthread_mutex_unlock(mutex_p);然后我们编译并运行,我们可以看到照样能过完成我们的加锁和解锁。
》如果我既想再传参数的时候,我既想传名字又想传锁,甚至有很多的属性我都想传,怎么办呢?我们用C的方式也能可以,这种方式叫做
typedef struct thread Data{
char name[NAMESIZE];
pthread_mutex_t* pmutex;
}thread Data;
那么其中呢,这里就有了一个叫做线程数据结构体,然后呢,我们在创建线程的时候,你不是有一大堆数据吗?那么我呢,每一次,在创建一个线程之前,我都可以做一个工作,这个工作叫做typedef我们的结构体struct threadData,即typedef struct threadData为threadData。然后我们用这个结构体定一个对象,即threadDatav* td = new threadData();构建出来之后呢,我们就可以填内容了。填什么呢,给我们定义的对象td填,因为threadData结构体有一个char name[]的话,我们用strcpy()函数去填里面的内容。比如strcpy(td->name, “thread 1”);td->pmutex = &mutex,同学们如果你愿意,这里封装个class然后构造函数来完成这些工作就行了。我呢,给大家写原生的。然后呢创建线程2、3、4都是按照这个来填参数,这儿我就不写了。
》我们把new出来的td传给我们的startRoutine()回调函数,也是通过我们的pthread_create()函数的最后一个参数传过去的,即pthread_create(&t1, nullptr, startRoutine, td);然后此时你在startRoutine()内部呢,你就可以做一件事情,我们定义一个threadData指针,即threadData* td = static_cast<>(args->pmutex),强转完成之后呢,然后传给我们的加锁函数pthread_mutex_lock(td->pmutex); 解锁也是同样的,pthread_mutex_unlock(td->pmutex);然后我们在cout里面,也能加上name打出线程的名字了,即cout << td->name << " get a ticket: " << tickets << endl;当然最后我们用完td这个对象之后,也要进行释放,即delete td;
》那么其中对我们来讲呢,每个线程都会做不同的事情,也可以在线程内部即可以在回调函数startRoutine()函数里面malloc一段空间,然后把对应的结果和数据呢,结果和数据不是对应的malloc嘛,然后通过回到函数的返回值void把它的地址也返回。换而言之呢,我想告诉大家的是,我们的主线程和新线程之间,两个人想进行值的交换呢,是可以通过我们各种所对应的堆区来进行交换,不一定非得硬传参数。所以当我们明白这个的时候,你也就知道了 ,如果一个线程,比如刚刚用threadData类型创建指针td这个对象的时候,即threadData td = new threadData()是在我们的主线程里去创建td这个对象的。创建好之后,依旧能在新线程依旧能看到。这说明什么,这说明对应的多个线程里面,地址空间内,堆空间也是具有可见性的。一会儿我们再做改写一会儿说。

下面,我们如果在多线程的环境当中,我们对于临界资源呢,临界区加锁,加锁之后,那么同学们,我有一个小小的问题,什么问题呢,就是我们在进行加锁的时候,我们是在if()语句上方加的锁,也在if()语句里面解的锁,那么中间那部分代码,是不是你所谓的临界区呀,如果是你所谓的临界区,那么同学们,在执行这个临界区的时候,当前的线程会不会被切换、阻塞、挂起呢?或者比如说,这个线程会不会切换?加锁的时候,解锁的时候会不会互相影响呢?有关系吗?
》首先我们得肯定的是,在加锁和解锁之间,我们一个线程是可以在任何时候被切走的。但是上面讲了,加锁,锁本身也要被多个线程看到,所以锁本身加锁的过程 和 解锁的过程必须是原子性的。它要保护别人,它首先得保证自己的安全性。但是如果我的一个线程在执行加锁和解锁之间的临界区代码时,因为我的时间片到了或来了一个优先级更高的线程,把我从CPU上剥离下来。比如说在临界区某一条语句被线程切换了,也就说,我申请了锁,当我正在访问临界区代码的时候,我可以被切换吗?我可以被切换吗?完全可以!因为线程执行的加锁和解锁等对应的也是代码。我们经常说,线程在任意代码处都可以被切换,但是线程加锁是原子的,所以当我们多个进程竞争锁的时候,你不存在中间状态,当多个线程在竞争锁的时候,那么最后的结果无非就是要么你拿到了锁,要么没有,不存在中间的锁被拿一半。
》所以加锁的过程是安全的,但是我把锁已经申请到了,但若是在锁中间被切走了呢?换句话说,我们的加锁,我们能理解,在临界区里面可以被切换,那么在加锁可以被切换的情况下,那么此时我被切走了,是不是其他线程,因为我不执行了嘛,是不是其他线程就来了,就访问我临界区的代码啦?我把这个问题再复述一下,一定要把细节想清楚。因为我们的临界区不大,我们整个线程的代码里面,只有一部分会访问临界区,一部分会访问临界区的话,那么如果我当前的一个线程,线程正在执行,执行完到临界区的某一条语句,就被切走了,它被切走了是不是可能存在未来有其他线程也来访问你对应的代码了?同学们,是不是呢? 相当于呢,我想告诉大家,我这里有一份代码, 然后它处于加锁和解锁之间,我的问题是什么呢,当我的线程申请到锁了,我在执行之间的代码的某一条语句的时候,由于线程切换,我被切走了,那我被切走了,会不会有其他线程来访问临界区呢?答案是:在我被切走的时候,绝对不会有线程进入临界区!!!同学们,很多的书,或者很多的教材,它不会跟你讲细节,也就是说我现在呢,你前面告诉我有多个线程在申请锁和释放锁,保护临界区,申请到锁的线程进到我们自己临界区的时候,它进到临界区就要做到先加锁然后再解锁,你说的没有问题。但是,我们之前对应的概念是什么呢,线程在任意地方都可能被切走,包括申请锁都有可能被切走,但是琐是原子的,不怕你切走,要么拿到,要么没拿到。所以当我拿到锁之后,我在我对应的临界区里面的时候,正在访问的时候,临界区是由多条代码构成的, 当前线程被切走了,切换成其他线程,怎么办呢?
》其他线程绝对不进入临界区的,为什么呢?因为每个线程都必须遵守一个保护临界资源的规定:每个线程进入临界区都必须先申请锁!!也就是说,你每个线程都必须遵守我所规定的要求,你想进入这个临界区访问可以,但你先别着急着直接访问,你不能直接,每个线程只要访问临界区,因为临界区本来就是被大家共享的,临界资源的概念不就是多个执行流访问我们的公共资源嘛。所以呢,既然是公共的,所有人不能直接进去,你得先申请锁,你既然要先申请锁,可是当前的锁,被,比如说被切走的线程叫做线程A,那么
当前的锁被线程A申请走了
,那么其中相当于什么呢?相当于,即便当前的线程A没有被调度, **因为它是被切走了,但是它不是一个人走的,它是抱着锁走的。换句话说呢,这个线程它被切走了,但是他没有释放锁,锁在线程A的上下文里面。他自己把自己,CPU把线程切走了,那么当前有新的线程到来的时候,不好意思,你申请锁你会被阻塞住,为什么,因为当前持有锁的线程还没有释放锁。这就叫做,我不在江湖,但江湖照样有爷的传说。什么意思呢,锁我拿着,我人不在,你其他线程想要进我临界区,你门儿都没有,因为我当前规定了,所有的线程在访问临界区时,它都必须先申请锁,你只能有了锁,你才能进入临界区。换句话说,如果一个线程,它当前持有者锁,它害不害怕切换呢,答案是,根本就不害怕!所以我最终的结论就是:一旦一 个线程持有了锁,该线程根本就不担心任何的切换问题!因为切换根本不影响我的访问,因为哪怕我不访问,我被切走了,那么当前我们其他线程想进入我们的临界区的时候,你也没门儿,因为你要申请锁,但锁被我抱着的呢。我抱着锁跑了,那么你此时就进不来,所以你也就不会影响我。所以,对于其他线程而言,比如说你是线程A,你申请到锁了, 进入到临界区访问, 你来回被切走,好你被切走了,那随便你,你拿着锁,我们也没什么办法,只能等你。所以对于其他线程而言,线程A访问临界区,只有,没有进入和使用完毕两种状态才对其他线程有意义。
》什么意思呢,你是个线程,我也是一个线程,我们两个都必须得先申请锁,然后再进入临界区,我申请了锁,然后我在里面墨迹了半天,那么此时对你来讲,你只能等。所以对你来讲,最大的意义是什么呢,就是要么,我已经使用临界区使用完了,要么就是我根本就没有进入这个临界区。因为此时只有这两种情况,你才有必要把你自己调用起来去申请锁。所以对我们线程而言呢,线程A访问临界区,对于站在其他线程角度,线程A在访问理解区的时候,只有它没有进入或者使用完毕这两种状态对其他线程才是有意义的,否则的话,其他线程都是被阻塞的。换句话说,站在其他线程角度,我们可以理解成,线程A访问临界区,也具有一定的原子性。其实严格意义上讲不太准确,但我们现在只能先这么理解。反正就是记住,当我线程持有锁期间,不怕你任何线程切换的行为,因为哪怕我被切走了,那么我被切走了,不怕呀,我走的时候,是拿着锁走的,那么你其他线程也别想进来,当只有我访问完的时候,你们才能够竞争我释放的锁。这就叫做,站在其他线程来讲,其他线程坐在一起吃着瓜讨论着,诶那货拿着锁又不释放,我们下次一定要赶在他前面,或者它把锁释放了,我们赶紧去抢。
》那么有的同学就又抬杠了,那按你这说法,线程A被切走,那么此时其他线 程也不能访问,那我如果在这个临界区里面,做一些特别耗时的动作,那么程序效率是不是太低了。所以记住了,我们要考虑一个问题,叫做,以后
尽量不要在临界区做耗时的事情!**一定是以最简洁的代码,来把你对应的临界资源的访问处理完。不要在里面写一些其他乱七八糟的程序,这没用知道嘛。如上就是上面遗留下来的问题。
在这里插入图片描述

❓下面的问题就是,我想来讨论一下叫做,线程加锁和解锁,尤其是加锁的原子性是如何实现的。这么一个话题。
》其实同学们,锁的实现有很多种方案。而我们讲的呢是比较常规的一种方案。但是我们得谈一谈它的原子性是如何体现的。在我们具体讨论之前,我相信,我们同学一定能理解一个的一个问题就是,这里的解锁,即pthread_mutex_unlock()函数,它其实就是一个我们对应的,没有人打扰它, 你想嘛,锁本来就是互斥的,你要解锁,你一定曾经申请了锁。你申请了锁,那么解锁这个东西是不是原子的,并不怎么重要。或者呢还是挺重要的,但是你不用担心,因为解锁一般都只有一个线程在解。就怕你解在中间了,在你解一半的时候,被别人拿走。所以解锁这个过程很好理解,走到这块,解锁的线程,很好理解它的原子性,一般解锁出现的问题很少。
》但是呢,我们得吧加锁,即pthread_mutex_lock()讲讲,它的原子性是如何实现的呢?我们之前写的所有代码,以及前面所写的内容都能意识到,单纯的i++,或者是++i都不是原子的。那么根本原因是因为这条语句呢,最终是由多条语句构成的。因为汇编之后,起码有3条语句。所以呢,我们要怎么样保证我们的原子性呢?很简单,只要你对应的代码能够在一条语句就能执行完,那么我们就可以称之为原子性。如果你不太理解的话,那么这一条一条语句呢就是二进制级别的一条语句,也就是我们CPU呢在取指令,分析指令,执行指令。然后对应的,你可能就对应的是一条汇编语句。那么它呢我们就可以称之为我们的原子性语句。但是呢i++和++i呢,本身i是在数组里面,计算是在CPU里面,所以一定会设计到各种把数据搬到CPU,CPU搬到内存,然后再在CPU内做计算。其实他很明显要做更多的工作。怎么做呢,为了实现互斥锁,大部分体系结构,所谓的体系结构,就是大部分的芯片结构,也就是X86 64等,他不同的体系结构里面呢,都有它对应的一个指令叫做swap或者是exchange这样的指令。那么他这个指令的作用呢就是把寄存器和内存单元的数据做交换,而且只用一条汇编。也就是一条汇编呢,就能够保证我们将内存和CPU的值做交换。因为它是我们所对应的一条汇编,所以当我们在做交换的时候,不会被切走,因为只有一条语句,要么执行,要么就不执行。即便是多处理器平台呢,访问内存的总线周期也是有先有后的。一个处理器交换指令执行时,另一个处理器的交换指令只能等待总线周期,这个话呢一会儿再给大家说。我们先把我们刚刚说的说了,就是,计算机体系结构里早就给我们提供了一个语句,这个语句叫做swap或者exchange汇编,它能用一条汇编的方式,将内存和CPU的值做交换,这句话应该能听懂。
》下面我再来谈谈,加锁。其中下面的伪代码呢,就可以理解成为,pthread_mutex_lock()语句。该怎么理解呢,我们也看到了呀。加锁的过程实际上也是一个有多条汇编语句构成的呀。那你怎么保证它的原子性呢。下面我们首先给大家做的,当你有两个线程的时候,或者有多个线程,同时或者较为同时来执行lock的汇编语句,那么执行申请锁的时候,每一个我们对应的线程都要执行,%al:呢是寄存器,然后,mutex:内存中的一个变量。下面呢,每一个线程都要执行move $0, %al这条语句,那么此时线程1、2 等每个线程都要执行这条语句,先问你们一个问题,那么线程1、2等都执行这条语句,然后 $0就想成0哈,它把0写到寄存器里,那么3个线程会互相的覆盖吗?同学们,也就是线程1它把数据写到寄存器里,线程2它又写了,线程3它又写了,此时会不会1、2、3,2把1的给覆盖了,3把2的给覆盖了。听清楚我的问题,我的问题是什么呢,也就是线程1、2、3同时往我们的寄存器里面去写,线程1把0写到寄存器里,线程2把0写到寄存器里,线程3也是,那会不会覆盖呢?线程1、2、3都执行这条语句的时候,它们是同时执行的吗?可不可能同时,只有一个CPU的时候它们是同时执行的吗?根本就不是,所以线程1在执行的时,它把数据写到了寄存器里,线程2再执行的时候,注意,凭什么让你线程2执行呢,是不是一定是曾经把线程1切换走了,把你线程2拿上去了,把你线程1切走了,**凡是在寄存器中的数据,全部都是线程的内部上下文!!多个线程看起来同时在访问寄存器,但是互不影响!**想象一下,线程1、2、3就开始执行第一条语句,就是这里的move开始,那么对我们来讲呢,这个问题我们曾经给大家说过很多次,我说过,寄存器只有一个,是被所有线程共享的。但寄存器里面的内容是被所有线程各自私有的,这叫做每个线程的上下文。所以你线程1执行move语句,把你的数据放到寄存器里,然后你又跟我说线程2要执行,同学们,凭什么你线程2要执行,我们CPU只有一个呀, 有的人说,我有多个呢,你有多个就多个嘛,有多个的时候那么线程1 和 线程2可以放在不同的CPU上跑嘛。如果你们两个要放在同一个CPU上,对不起单CPU只能让一个线程在跑,不考虑你多核的情况。所以对我们来讲呢,我线程1在执行move动作,我就以线程1为主。然后再给大家谈,当我们明白这个的时候,下面我来给大家画一张图帮大家理解一下。
》我们有一个CPU、物理内存,我们mutex是在内存的某一个位置。我们CPU内呢,有一个%al,即寄存器。那么接下来,我们所对应的mutex,我们当前线程A执行第一条move语句,把0写到了寄存器里。写完0之后呢,线程A继续执行第二条语句xchg…,exchange做什么呢,它把寄存器里面的值和内存的值用一条语句做交换,所以它呢直接做了exchange,注意这可使一条汇编,一条汇编做交换,交换的什么呢,是不是相当于一条汇编的方式,把寄存器里面的值放到了我们对应的mutex,把mutex的值放到了寄存器里,此时这个动作就叫做加锁!**交换就是完成加锁!**然后呢,线程A再去判断,寄存器里面的内容,线程A能过往寄存器里面写,它当然可以从寄存器里面读,可以读的话也就能比较,所以线程A在执行第三条语句,再在我们的寄存器里面判断 ,当前呢把数据,寄存器里面的值是不否大于0,大于0就return 0 代表申请锁成功,如果不大于0,就等待挂起,说明当前就没有申请成功。这是线程A,假设线程A此时它呢,把锁拿走了,线程B他又来了,线程B它也是执行申请锁的动作,它也照样要进行我们的对我们的锁进行lock加锁,线程B干什么呢,它也要执行对应的代码。请问同学们当线程B来的时候,线程A去哪儿了?
》同学们,这里有一个非常隐晦的词,叫做当我们的线程B来的时候,线程A必须从我们的CPU上剥离下去,这个非常重要。当线程B来的时候,线程A你必须得从CPU上剥离走,剥离走就剥离走,剥离走的话,你要把你的上下文带走。假设同学们,线程A呢刚刚交换完成,线程A呢正准备判断寄存器里的值是否大于0,但此时线程B说赶紧下去让我来,所以此时线程A就被切换走了,线程A被切换走,线程B来了,都要执行加锁,所以线程B也要执行move ,把 0 move到%al寄存器里面。线程B呢它也给寄存器写了0,写完0之后呢,线程B也要执行下面一条汇编,做交换。交换就交换呗,可是 mutex 里的曾经的1,已经被人拿走了,只剩下一个 0 了,剩下一个 0 就相当于我们对应的B呢,它拿着 0 来换0,那么此时你再接着执行吓一跳汇编,判断的时候,再怎么判断你的值都不可能 >0,那么就要被挂起等待。此时线程B就叫做申请锁失败。
》下面我们再来谈一个,有同学会说,不对呀,我们可以在线程的任意地方,你不是加锁吗,加锁的每一行代码都可以被切走呀。是的,每一行代码都可以被切走,没问题。线程A你来了,线程A正准备执行move,假设线程A刚给寄存器里写了0,线程A正准备做交换的时候,线程A说,我要交换,线程B说,让开,一边去,让我先来。所以线程A被强力下线了,强制被切走了,被切走不怕,为什么,因为切走的时候,都要带走自己的上下文,当线程切换的时候,这是基础哈,一定要记得。所以线程A说,好吧。我现在刚把第一条语句执行 ,你就让我走,走就走,所以线程A把寄存器里的值带走了。同学们,应不应该呢?应该的,因为这是你的上下文,带走吧。带走怎么办呢,线程B说,我要加锁,那怎么办。线程B你要加锁,可以呀,你也要执行对应的lock里面的语句。你也必须从第一行开始执行。所以线程B也要执行第一条语句,叫做我们可称之为,把0写到寄存器里。把0写到寄存器里。,线程B很happy的继续执行第二条语句,它执行第二句交换的时候,用一条汇编,这是体系结构给我们提供的汇编。怎么办呢, 那么它呢把我们寄存器里的值和mutex里面的值,两个进行做交换。交换的时候呢,用一条汇编就完成了,对其做交换的过程。交换完成之后,线程B说,我终于拿到这个 1 了。拿到这个1怎么办呢,线程B要做判断,线程B要做什么判断呢,它要做我的寄存器里的内容是否 > 0 ,它正准备做判断的时候,请问线程B可不可以被切走?任何地方都可以!线程A回家之后越想越气,凭什么你让我走就走,我要和你抢,所以线程A把自己的优先级调的更高,跑过来和线程B算账,线程A说,我也要来抢锁,你赶紧给我走。 所以线程B一看到线程A来时汹汹的,行吧我就让你执行。所以线程B就走了,线程B走了怎么办呢,不是平白无故你线程B就真的走,而是每一个线程在进行我们所对应的,被切走时,必须将自己的上下文数据带走,所以线程B也把自己的数据拿走了。拿走之后,那么线程A一边骂骂咧咧,一边把曾经对应的数据恢复到寄存器里,那么此时线程A再继续执行曾经准备执行的第二条交换语句,线程A终于找回了自己的面子,但是发现自己的锁不见了,为什么,它继续执行交换,交换就交换呗,此时寄存器里的值和mutex交换,可是它只交换来了线程B曾经留下来的叫做 mutex 为 0的值,线程A继续执行判断语句,发现完了,这个线程B把我 mutex里面的 1 拿走了,此时只能是else,被挂起了。被挂起,意味着线程A不要运行了,线程A只能生气的把自己上下文再带走。然后让线程B再回来。也就是说,线程B我不运行,只要我把锁拿走了,所有人都无法申请成功。所以线程B慢慢悠悠的回来了,此时回来的第一件事情,就是恢复我的数据,然后继续执行准备执行的判断语句,发现寄存器里面的内容是。> 0 的,大于0怎么办,那线程B申请锁成功。这是第二个场景。
》最后给大家说的结论是什么呢,那么其中lock里面的,exchange叫做申请锁的过程,同学们,**申请锁的本质是:将数据交换到CPU内的寄存器内部。同学们我们在申请这个锁,mutex是一个全局的变量,不就是在内存里面吗,多线程情况下,你定义的全局变量被所有线程都是共享的,所以mutex这个变量属于所有线程。**但是,同学们,它属于所有线程,但是一旦用一条汇编语句将内存中的mutex里面的值就交换到CPU内的寄存器,同学们,CPU的寄存器内部这句话的本质是什么呢?这句话的本质叫做,线程的上下文内部。什么意思呢,就是你只有一条对应的exchange汇编,得先把这个mutex变量的值,用条汇编交换到寄存器里,寄存器内部谁使用就是谁的上下文。所以其实我们把数据交换到寄存器内部,本质就是把锁拿到自己的上下文里面,只要我交换到了,以后所有的线程想申请,你不可能申请到了。因为我在申请时,我再继续往后运行时,这个数据已经只属于我个人了,**所以本质,将数据从内存读入我们CPU中的寄存器,本质是将数据从共享变成私有!**只有体会到这点时,才算你真正把线程的加锁过程真的理解。那么,同学们,因为我把数据从内存交换到CPU寄存器里面只有一条语句,只有一条语句,而且最好玩的是,我们所有的人要和我的mutex换,所有人手里都拿着0,都拿着数字0来喝mutex换,我mutex 是 1,可是同学们,因为我们不是拷贝,不是拷贝而是交换,所以 1 永远只有一个,这个 1 就如同一个令牌一般。哪个线程交换的时候,拿到这里的所对应的1,那么哪个对应的线程就拿到了锁,这就是加锁是原子的。所以你现在能理解为什么加锁是原子的了吗,它加锁就是采用的这么一个规则。当然同学们,我说了,加锁我刚刚说的这个呢,实际上是锁的一种实现方式。

在这里插入图片描述
》锁还有很多种底层实现方式。比如说,CPU和内存之间是有总线连接的,在加锁的这件事情上,还有一种特别简单粗暴的做法,什么做法呢。就是多线程在执行的时候,每个线程都在使用CPU内的寄存器,然后呢把数据进行放到进程寄存器里,再被操作系统调度。可是呢,我们计算机也提供了一种方式,让总线被锁住,即锁住总线。锁住总线呢就相当于,我当前的线程,我在运行的时候,其他的线程,我把总线锁了,其他线程的指令不会被读取。也就是说,在一瞬间我把总线锁住,不会有任何的代码会被执行,然后在我锁住总线期间,我安安稳稳的把锁创建好,把锁申请到。比如说mutex由 1 改成 0.然后我们再释放被锁住的总线,这个工作呢,实际上是可以做到的,但是这个地方呢我们不考虑它的锁的应用实现,你大概知道就可以了。所以才有了一种说法,即便是多处理平台,访问内存,总线周期也有先后,因为即使是多CPU,但是总线只有一套。不可能你CPU,你A CPU往总线放地址和数据,我B也是,那么地址和数据什么的全都乱了。它实际上也是要能够通过总线进行,锁住总线,哪个CPU要用,谁先锁住总线,谁才能访问内存,通过这个方式实现的,这个东西对我们做软件来讲有点远了。我们只需要考虑我们上面讲的那个互斥锁的加锁原理就可以了。
》同学们你可以看到,其中当我们申请锁时,最本质的是,锁就是1,而且我们交换就只有一条汇编。我们要理解从内存里交换到CPU的寄存器里的本质是什么。它的本质就是从共享变成私有。因为我们CPU内的寄存器的数据是线程私有的,在被切走后,会将数据的上下文一并交给当前线程带走。关于锁全部讲完,解锁就不怎么说了。解锁的过程就相当于呢,你直接把1再写入到mutex里面,没人跟你抢,而且这也是一条语句,最后呢,你想释放锁时,比如线程B拿着锁,它把临界区代码执行完了,它要怎么办,它把1再写回mutex里面就好了,这个时候就完成了解锁。解锁之后呢,线程B可能和线程A呢继续去抢锁,那门重头执行加锁的过程。
》我们的互斥锁呢,全部讲完,然后我们把课件上的一批概念再说说,想把概念呢给同学们稍微讲讲。还有一些常见锁的概念,也一会儿在下面说。我们还会停下来给大家写一份代码,至少对锁得给大家做一个基本的封装,让同学知道他怎么用。我们把基本理论和锁的使用讲完之后,我们再谈常见锁的概念,紧接着我们就进入下一个:条件变量就可以了。锁分很多种,我们的挂起等待锁,最典型的就是互斥锁,以及未来要学习的信号量,我们同步相关的条件变量,我们重点学这3个,还有什么自悬锁,这个概念我们不考虑,剩下的同学们听过的很多的锁的概念都是基于这些原生的锁做的变形,我们把常见的一讲就OK。

Makefile:
mythread:mythread.cc
	g++ -o $@ $^ -lpthread -std=c++11

.PHONY:clean
clean:
	rm -f mythread
pthread_mutex_t mutexA = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutexB = PTHREAD_MUTEX_NITIALIZER;




#define NAMESIZE 64

typedef struct threadData
{
    char name[NAMESIZE];
    pthread_mutex_t *mutexp;
} threadData;

void *startRoutine(void *args)
{
    pthread_mutex_t *mutex_p = static_cast<pthread_mutex_t *>(args);
    threadData *td = static_cast<threadData *>(args);

    while (true)
    {
        pthread_mutex_lock(td->mutexp); //如果申请不到,阻塞线程
        if (tickets > 0)
        {
            usleep(1001); //线程切换了
            cout << td->name << " get a ticket: " << tickets << endl;
            tickets--;
            pthread_mutex_unlock(td->mutexp);

            // 你还有其他事情做
            usleep(500);
        }
        else
        {
            pthread_mutex_unlock(td->mutexp);
            break;
        }
    }

    // malloc() -- result
    return nullptr;
}

int tickets = 1000;
Mutex mymutex;

// 函数本质是一个代码块, 会被多个线程同时调用执行,该函数被重复进入 - 被重入了
bool getTickets()
{
    int err = errno;
    fopen()....

    errno = err;
    static int cnt = 0; //统计该函数被调用的次数

    bool ret = false; // 函数的局部变量,在栈上保存,线程具有独立的栈结构,每个线程各自一份
    LockGuard lockGuard(&mymutex); //局部对象的声明周期是随代码块的!
    if (tickets > 0)
    {
        usleep(1001); //线程切换了
        cout << "thread: " << pthread_self() << " get a ticket: " << tickets << endl;
        tickets--;
        ret = true;
    }
    cnt++;
    return ret;
}
void *startRoutine(void *args)
{
    const char *name = static_cast<const char *>(args);
    while(true)
    {
        if(!getTickets())
        {
            break;
        }
        cout << name << " get tic kets success" << endl;
        //其他事情要做
        sleep(1);
    }
}

int cnt = 10000;

int main()
{
    static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

    malloc 底层一定要调用底层申请函数,brk,
    pthread_t t1, t2, t3, t4;

    std::mutex mutex_;
    mutex_.lock();
    mutex_.unlock();

    {
        //临界资源
        LockGuard LockGuard(&mymutex);
        cnt++;
        ...
        ...
        ...
    }

    lock();
    cnt++
    unlock();

    threadData *td = new threadData();
    strcpy(td->name, "thread 1");
    td->mutexp = &mutex;
    pthread_create(&t1, nullptr, startRoutine, (void *)"thread 1");
    pthread_create(&t2, nullptr, startRoutine, (void *)"thread 2");
    pthread_create(&t3, nullptr, startRoutine, (void *)"thread 3");
    pthread_create(&t4, nullptr, startRoutine, (void *)"thread 4");

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);

    delete td;
}

下面呢想带大家写一份代码,刚刚呢更多的是学习了锁的接口和锁的原理,下面我们把应用再做做,最终,我们之前讲的管道,我们把管道讲了,通信能通了,但是这个管道你得让我看到怎么用吧,所以我们自然想到用管道,用主进程控制子进程的代码。那么晚我们今天学到了锁,这个锁呢,其实可以把它设计一下,我们把它写完再进入下半部分的概念。下面我们做一个简单的封装。
》下面呢,我们既然已经有了一把锁,那么我们该怎么更好的去使用这把锁呢?我们加一个lock.hpp 。下面呢我们把锁进行简单的封装,怎么封装呢,慢慢说。我们定义一个Mutex类,然后,里面有一个由pthread_mutex_t定义的锁的成员变量;有了这把锁之后呢,把构造和析构都写出来。我们这个Mutex要给我们提供一个lock()方法和unlock()方法,叫做加锁和解锁。那么对我们来讲呢,返回值就是void。下面我们Mutex在构造的时候,调用pthread_mutex_init()函数初始化,即pthread_mutex_init(&lock, nullptr);然后析构函数呢,就要进行调用pthread_mutex_destroy(&lock),来进行释放锁。我们的lock()加锁方法呢,调用pthrad_mutex_lock()函数,即pthrad_mutex_lock(&lock);然后呢unlock()方法就是调用pthread_mutex_unlock(&lock)。所以到现在呢,我们就封装了一个简单的锁这样的东西。
》我们再写一个Lock_GUARD的类,我们封装一个Mutex对象的成员变量,然后就是构造函数和析构函数,我们构造函数呢所需要的函数参数是一把锁,所以你创建Lock_GUARD对象你就要传进来一把锁。我们在初始化列表阶段呢,初始化我们的锁,即mutex_(mutex),然后呢,在{}里面用mutex->lock()方法,在析构函数里面用mutex->unlock()方法,所以呢对我们来讲,我们下面的一个工作呢,对应的概念呢就是我们创建了Lock_GUARD的对象传参的时候,传进来一把锁,然后我们对我们Lock_GUARD类里面封装的Mutex类的mutex_进行初始化。我们在构造函数的时候,进行调用Mutex中lock加锁方法,在析构函数调用Mutex中的unlock解锁方法。
》我们写完之后怎么用呢?我们还是用我们的抢票代码。首先我们要抢票就得来一个逻辑叫做getTick(),其返回值为bool,抢票的时候,有一个全局变量int tickets = 1000;然后抢票内部逻辑就是用的startRoutine()里面的部分代码。换而言之,将来哪个线程调用这个getTick()函数,就会执行对应的抢票逻辑。我们getTick()函数返回值为bool,那么我们定义一个bool类型的变量,bool ret = false,默认是false;只要我们抢票成功就是执行了tickets–,就ret = true,就是抢成功了,就return ret;那么我们线程要执行的回调函数是void
startRoutine(void* args)。所以就相当于什么呢,相当于,我们创建了4个线程,每个线程都会执行我们对应的叫做线程相关的创建动作,我们创建线程的函数phtread_create()函数的最后一个参数我们就传函数的名字吗,即pthread_create(&t1, nullptr, startRoutine, (void*)“thread 1”),然后根据这个模版创建4个线程。所以现在呢,就相当于有4个线程,它们都会调用startRoutine()然后去执行抢票,具体抢票的话,回去getTick()里面去抢,只要你抢到了最终就会返回return ret。但是同学们,有一个什么问题呢,那么我们抢票的时候,是不是就必须得保证,我们称之为叫做抢票里面有一部分是临界区,要保护临界资源的安全性。那么你怎么抢呢,那肯定是在startRoutine()函数里用while循环,每个线程不断的去抢票。然后我们编译运行我们的代码,可以看到不同的线程都在抢票。
》但是这份代码和我们之前一样是有问题的, 它是没有锁保护的,我们能理解,想要复现的话也很难复现出来,没关系,之前我们都看过了。因为当前抢票的代码呢,相当于我们没有抢成功过,或者叫我们临界资源在被访问,就是临界区,每个线程都会执行这个getTick()函数调用,这个getTick()函数里面呢就对应着有一大堆访问临界i资源的操作,所以其为临界区。那么我们就要对临界区加锁,那怎么加锁呢?加锁就相当于我们就要有锁。之前呢,我们是定义一个全局的锁pthread_mutex_t mutex;我们在getTick()函数里面的if()之前加锁,在访问临界区后,在if()外面进行解锁。但是呢现在就不这么干了,以前这么干可以,未来这么干也可以,只不过呢,我们今天可以用我们封装过的锁。
》我们在全局用自己封装的类型Mutex定义一把锁,即Mutex mutex;定义好后,因为这个mutex是在定义的时候,在构造对象的时候就会初始化我们的原生系统里面的锁,析构会自动释放锁。又因为我们在全局定义的这把锁,一定是会被所有线程共享,所以没有问题。然后接下来抢票呢,每个线程都会执行getTick()函数,getTick()函数是一个代码块,函数的本质是一个代码块。所以我今天要对整个代码做保护,做加锁怎么办呢,我们就用到我们另一个封装锁的类型Lock_GUARD,用该类型定义一个对象,即Lock_GUARD guard(&mutex);此时我们就完成了在代码块getTick()函数中加锁操作。什么意思呢这个Lock_GUARD对象在调用构造函数时候就会自动去调用对应类型Mutex的Lock()方法进行加锁操作,每一个线程想要访问临界区都要去申请锁,可是只会有一个人申清锁成功。然后我们Lock_GUARD guard(&mutex);局部对象的生命周期是随代码块的,所以正好出了getTick()函数作用域,就会调用Lock_GUARD类型的析构函数,会去调用Mutex里面的UnLock()方法,进行解锁。
》所以至此,我们就写出了RAII风格的加锁方式!
》比如说,我们后面有一个全局变量cnt,int cnt = 1000;我现在要进行对cnt加锁,以前我们是lock();cnt++;unlock()这样子去写的;我们现在构建代码块{Lock_GUARD gurad(&mutex);cnt++},当退出{},即退出代码块时,guard局部变量生命周期随代码块,自动销毁调用析构函数进行解锁。
》我们已经把锁是什么,底层原理,锁是原子是怎么做的,第二个,上层锁应该怎么用,我们怎么去设计封装它我们都写了,后面呢我们在给大家写一个对线程的封装,以前我们在写线程的时候,都是原生的去写调用pthread_create()函数,我们C++里面有自己的线程,它是封装的,我们也类似做一下封装。另外我们在C++11里面也有也就是锁的头文件。其实把我们刚刚讲的掌握了,很容易理解它的封装。我们来写写给大家看。他就是封装了原生的线程库。我们把原生的弄懂了就行,封装的永远是为了让我们更简单。
》std::mutex mutex_;mutex_.lock();mutex_unlock();这就是c++11里面的加锁和解锁。不说了。

#include <iostream>
#include <pthread.h>

using namespace std;

class Mutex
{
public:
    Mutex()
    {
        pthread_mutex_init(&lock_, nullptr); // 对锁进行初始化
    }

    void Lock()
    {
        pthread_mutex_lock(&lock_); // 加锁
    }

    void UnLock()
    {
        pthread_mutex_unlock(&lock_); // 解锁
    }

    ~Mutex()
    {
        pthread_mutex_destroy(&lock_); // 当锁不要了,要释放锁
    }

private:
    pthread_mutex_t lock_; // 先声明一个锁
};

class Lock_GUARD
{
public:
    Lock_GUARD(Mutex *mutex)
        : mutex_(mutex)
    {
        mutex_->Lock();
    }

    ~Lock_GUARD()
    {
        mutex_->UnLock();
    }

private:
    Mutex *mutex_;
};

int tickets = 1000;
Mutex mutex;

bool GetTick()
{
    bool ret = false;
    //加锁-----➡️我们不按以前那样加锁操作了,我们现在定义一个我们封装好的类型就行。
    Lock_GUARD guard(&mutex);----➡️就这么一行代码就实现了加锁和解锁的操作,RAII风格的加锁方式
    if (tickets > 0)
    {
        cout << "thread: " << pthread_self() << "get a ticket: " << tickets;
        tickets--;
        ret = true;
    }
    //解锁
    return ret;
}

void *startRoutine(void *args)
{
    while (true)
    {
        if (!GetTick())
        {
            break;
        }
        cout << name << " get tic kets success" << endl;
        //其他事情要做
        usleep(1000) // 还有其他事情要做,所以用usleep()代替;
    }

    return nullptr;
}

int main()
{
	/*------➡️以前是这样写的,但现在不这样写了
		lock();
		cnt++;
		unlock();
	*/
	
	{------➡️现在可以这样写,{}相当于代码块,当退出代码块时,局部变量guard自动就调用析构函数解锁
		Lock_GUARD guard(&mutex);
		cnt++
		.....//全部都是临界区
	}
	
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, nullptr, startRoutine, (void *)"thread 1");
    pthread_create(&t2, nullptr, startRoutine, (void *)"thread 2");
    pthread_create(&t3, nullptr, startRoutine, (void *)"thread 3");
    pthread_create(&t4, nullptr, startRoutine, (void *)"thread 4");

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);
}

我们把锁全部讲完,从底层的基本原理,接口封装,再到我们的基本使用,我们全部都搞定,所以锁的上中下我们都搞定了,叫做可重入和线程安全。都是一批概念。
✳️可重入函数
》同学们,个大家们说一下,可重入这个东西,以前我们在讲什么的时候见过,讲信号的时候 我们提过。当时我们说过,有一个链表,主函数呢它要对我们链表呢进行头插,然后信号处理函数又要对链表进行头插,那么此时头插方法就相当于重入了。那么今天对可重入函数的理解就无比的简单了。同学们,我们刚刚为什么要故意写一个getTick()函数,它会被多个线程同时调用执行,那么同学们,也就是说这个getTick()函数呢,会在我们线程执行的代码里面呢,可以同时被调用执行,所以这个函数,我们就叫做该函数被重复进入,即被重入了。也就是说我们写的getTIck()函数就是可重入的。但是被重入的时候,你们不知道有没有发现一个细节,我在加锁的时候,并没有把我加锁的过程没有放在bool ret = false;上方,而是放在它下面,为什么呢?因为这个ret变量叫做函数的局部变量,局部变量在哪儿保存呢,在站上保存,我们早在讲线程概念的时候说过,线程是具有独立的栈结构。好了同学们,线程既然具有独立的栈结构,意味着什么呢?各位少年,线程具有独立的栈结构,是不是就以为着像bool ret = false变量,每个线程各自私有一份,这样不敢说私有,同学们,我问你,如果我想读到这个变量,你说我能读到吗?我想让其他线程,比如线程1、2、3任意一个线程读到另一个线程ret可以吗?可以的。比如你定义一个全局指针,全局指针定义好,定义好之后呢,比如说你定义一个全局bool类型的,即bool* b;此时你用b指向局部内的ret,指向后其实也能访问到,谈不上私有,应该称作每个线程各自一份。也就说我们各自一份,影不影响取决于你,你想不想让其他线程看到,所以我们各自有一份,一般是不会去影响到。像tickets这样的全局变量,这种全局变量就是我们所对应的系统。那么影响的就是我们所对应的整个系统稳定性,因为它是一个临界资源。
》那么所以对我们来讲,我们清楚到了这一点之后呢,我们知道了,刚刚我们的这个函数呢叫做被重入了。那么如果在我们重入期间呢函数没有出问题,那么这就叫做我们所谓的这个函数是可以被重入的。
》下面我们来谈一谈几个概念,然后线程安全再来一个。
》常见不可重入情况:
·调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的(给大家稍微讲一下,malloc底层一定要调用操作系统的内存申请函数,那么它其实是分两批调用的,底层调用呢,它调用的是系统接口brk。第二我们曾经讲过地址空间,我们对应的进程地址空间呢,那么我们曾经说过一个东西, 叫做堆区。你曾经给我讲过,代码区从哪开始到哪结束,全局数据区从哪开始到哪结束,包括栈区从哪开始到哪结束我都能理解,为什么呢,因为不管是代码区还是数据区,它的起始和结束都是固定的。栈区再怎么调用的时候,它有寄存器去指向栈底或者栈顶。那么它的范围呢也是可以在地址空间里直接用什么start、end这样两个数值来表示虚拟空间,我能理解。但堆区不一样,堆区可能被申请了很多次,堆区里面是有很多很多的小块空间,堆的管理它不仅仅是用我们的虚拟地址空间管的,堆区在内核里面,它还被更细的用我们的数据结构 vm_area_struct结构管理的,它呢是一张链表,它里面呢也是有start和end,也就说呢这个结构是可以来表示一小块一小块的,你有10块的话,那么链表所对应的vm_area_struct。比如说我们malloc10次,malloc10次的话呢,有可能操作系统就会维护一个具有10个节点的,10个vm_area_struct对象的链表,每一个链表填的就是你在堆区里面的start 和 end,这样的话呢,你上层每一次拿到的是,同学你们申请堆区的时候,是不是把每一个堆区的起始地址都拿到,起始地址就是vm_start,它怎么知道越界了呢,因为有vm_end嘛。所以呢,在我们内存里可以有非常非常多的 ,我们可以称之为叫做堆区的一个个小堆,说白了,堆区你可不是一整块整块的使用的。不像栈、代码区、数据区或者全局数据区,它们都是固定的,但是堆区的大小呢,它虽然是向上生长的,但是堆区里面有很多的小块,小块就使用vm_start和vm_end来做的。然后我们经常会说,说什么呢,叫做操作系统它会定期的去做碎片整理等乱七八糟的,说白了就是针对进程内部做相关的内存整理。一般操作系统在做的时候,实际上呢就是对你这个vm_start和vm_end区域呢在内存当中重新给你把空间申请成连续的,重新调整页表映射,这个不重要,我们能把以前说的地址空间理解好了就行了。)
·调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构(比如同学们你经常听说的errno,那个就是一个全局数据结构,再者就是我们曾经讲的环境变量environ指针他也是全局的)
·可重入函数体内使用了静态的数据结构(比如说是static)

》常见可重入情况:
·不使用全局变量或静态变量
·不使用用malloc或者new开辟出的空间
·不调用不可重入函数
·不返回静态或全局数据,所有数据都有函数的调用者提供
·使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据(其中对我们来讲呢,什么意思呢,比如说给大家举个例子,今天我们在getTick()里面调用了C语言接口,因为我们知道errno是全局的,当进入代码时,这个代码天然就有一个全局变量叫做errno,比如我们在代码内部调用fopen()打开一个文件,如果文件不存在就会设置我们的errno,那这样就有问题,怎么办呢,没关系,我们来一个int err = errno;也就是说呢,接下来我们调fopen()等各种的接口,然后呢,你去调,中间出错了不影响,等你退出的时候,你再errno = err 。相当于呢,在fopen()等接口当中呢即便你出错了, 函数调完之后呢,也会吧曾经的值恢复上来,更关键的是,每一个线程调用该函数,它都会这么干,所以最终呢errno不会被所有的人,可能你errno中间再怎么改了,但最终errno的值也不会被大家做各种各样的处理,并且呢,你自己其实在对应的代码当中呢,你也可以拿到我们对应的一个叫做,相当于我们每一次把它保存起来,中间用完之后,我又把它恢复出来,这样的话就不会出现我们多线程并发,导致errno乱写的情况。)
✳️线程安全
线程安全是什么呢,线程安全是多线程并发的执行一段代码时,它不会出现不同的结果,那么最常见的对全局变量和静态变量操作,在没有锁的情况下会出现这个问题。同学们,所以我们之前写的抢票逻辑,没有加锁的时候,叫做线程不安全。为什么呢,因为我们每次调用抢票的时候,那么总是会出现不同的抢票结果。有的是正常抢完,有的是抢到-1或者-2等结果。这种情况下就是我们所对应的线程不安全。另外呢,还有一种情况就是,一个全局变量,一个线程使用全局变量做循环,一个对全局变量做修改。那么一个对全局变量做修改,那么恶意的修改可能会影响其他线程,那么这叫做线程不安全。再比如,一个线程它有bug,直接出现了问题,导致其他线程的退出,这也叫做线程安全问题。所以线程安全是在并发情况下,因为一个线程的执行只要能够影响其他线程,或影响整个程序的执行结果,带来的一个不同,就相当于让我们结果出现不同了,就是不稳定。这样的情况呢,我们都称之为线程安全问题。而可重入函数呢,它不是一个问题,重入它是一种现象,他不是问题,我个人认为他不是一种问题,它更像是一种现象或者特性。一个函数被重复进入,那么其中呢我们叫这个函数被重入,在这个函数被重复进入的情况下,你执行代码出现问题,这叫做该函数是不可重入函数。如果此时,一个函数在重入的情况下,不会出现任何问题或者不同,这个函数被称为可重入。换句话说,多执行流掉一个函数时,重复调用该函数,没有出问题,那么这个函数就是可重入,如果出了问题,就是函数不可重入。同学们,有的人说这就是问题呀。这不是问题,为什么呢,因为函数可以被重入,或者不可以被重入,这是函数的特性,我们现在百分之90都是不可重入的,尤其是你们用的C++和STL,百分之百的STL容器几乎都不是线程安全的,也是不可重入的。换而言之,你说他错了吗,没有。如果一个函数他已经告诉你我是可重入的,那你就多线程调用,这叫做没有问题,但是如果一个函数说,我是不可重入的,那么你还非得说,我得调一下你,我得多线程调你。最后出了问题,你还甩锅给函数,你说你怎么不是可重入函数呢。函数没错,如果出了这样的问题,是你这个程序员不专业,你写的东西写错了,你不应该在多线程的情况下并发的去调用不可重入函数,或者你要对他做保护。所以呢,重入不重入它不会是一个问题,它是一种特性,是一种指导程序员写代码的特性。而线程安全问题是一个问题。
》所以同学们我刚刚说了一观点就是,线程安全,它在并行的时候,他可能会出现不同的结果, 即便是你的在加锁的情况下,你的代码出现了问题,逻辑上有问题最后导致数据不正确,它也是线程不安全。那么线程不安全的情况有哪些呢?另外可重入函数是一个特性,你像我们刚刚写的getTIck()函数它是不可被重入的函数,也就是在多线程执行的时候会出问题。,那么既然它不可被重入怎么办呢,怎么解决呢? 因为它造成了线程安全相关问题,比如抢票出问题,那怎么办呢?没关系,我们把它串型化起来,也就是我们加锁的过程,实际上本质是吧我们多线程串型起来,让我们每个线程安全的调用这个不可重入的函数。所以面对不可重入的函数时,它不是问题,它是一种特性,那怎么解决呢?一般我们都是通过加锁来进行串型化。串型化后不就是一个一个来嘛,既然不可重入那就一个个来嘛。
》那么常见的不安全情况比如:
·不保护共享变量的函数
·函数状态随着被调用,状态发生变化的函数(比如说在getTick()函数内,static int cnt = 0;cnt++;这里会有问题,为什么呢,实际上是我想统计该函数被调用的次数,没调用这个函数,这个cnt只会被初始化一次,然后每次调用都会++,因为我们知道cnt++不是原子的,所以可以将它放在临界区里面做保护。其中对我们来讲呢,这样就叫做函数状态呢,你每调一个函数,状态就变了,因为计数器会发生变化,状态发生变化的函数,如果不加锁情况下是不可重入的,也可能有线程安全问题,怎么办呢,我们就要加锁。 )
·返回指向静态变量指针的函数(因为你在返回别人也在返回,一旦返回了,等于多个线程会指向同一个变量,那么未来你要返回的指针,线程A拿到,线程B拿到等,谁知道线程A、B、C会通过这个指针干什么)
·调用线程不安全函数的函数(比如说你这个没有加锁的抢票,多线程调用那就是不安全的。)
常见线程安全的情况:
·每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的(比如说,我们有一个全局变量,我们不写你,我们只读这个时候没有线程安全问题。)
·类或者接口对于线程来说都是原子操作(比如说, 我们通过加锁)
·多个线程之间的切换不会导致该接口的执行结果存在二义性(就好比刚刚,我们上面说定义一个static int cnt = 0; cnt++;统计函数被调用了多少次,如果你把cnt放在加锁之前,其中呢,我们可能正准备++的操作,那我被切走了,本来是我++要变成1的,我被切走了,切走之后其他线程疯狂调用,调用了一万次,然后我被切走的线程回来了,啪的一下将1写回内存里,把一万变成1了,曾经人家好不容易调用了一万次,一下被你改成1了,这也是有问题的。所以要加锁。)
》可重入与线程安全联系:
可重入和线程安全的联系是两套逻辑,可重入和不可重入呢,是描述函数的是否可以被重进入的。线程安全它描述的是一种多线程并发带来的问题,所以函数是可重入的,那么就是安全的。一般函数是可重入的,线程就是安全的,为什么呢,同学们,可重入的定义是什么,叫做可以被重复进入不会出现问题, 这不就是线程安全嘛。所以函数可以被重复进入,那它一定是线程安全的,如果函数是不可重入的,那它就不能由多线程同时使,一定是因为它引发了线程安全的问题,可重入和不可重入就是线程安全的定义嘛。所以一个函数如果有全局变量,那么这个函数既不是线程安全,也不是可重入的。
·函数是可重入的,那就是线程安全的
·函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
·如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
》可重入与线程安全区别:
·可重入函数是线程安全函数的一种
·线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
·如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。(这里说的有点不太对,如果一个函数还未释放锁,那么没关系,其他线程进不来,进不来呢,但是并不代表你不会释放锁,所以加锁的本质上呢其实已经不再是可重入和不可重入的问题,而是加锁就是为了保证我可以串型的执行你这个函数,所以它本质上是,因为我知道你这个函数是不可重入,所以呢我就加锁,让它串型的去执行。)

✳️常见的锁的概念:
这个常见的锁呢,不是讲各种的锁,而是讲一个概念叫做死锁。死锁这个概念呢也是在学校里老师会讲的,今天呢就借讲多线程来提一下,但我们的重点不在于死锁怎么办,而是要预防死锁,所以呢我们先来谈一下死锁。
》死锁是指一组线程或者执行流,各个线程或者进程均占有资源不释放,然后呢进而互相申请其他进程资源,进而造成双方都会进行永久等待等待的情况,这就叫做死锁。那么关于死锁的问题呢,我呢也可以给大家模拟一下死锁的情况。
》什么叫死锁呢,最简单的死锁呢,我们定义一个pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;初始化完之后呢 ,我们今天定义两把,mutexA 和 mutexB,现在呢就相当于有了两把锁,我们线程1呢要执行的就是打印 ,假设线程1呢要申请锁,pthread_mutex_lock(&mutexA),pthread_mutex_lock(&mutexB)它必须得拿到锁A和锁B,然后才能去打印,同样的也要去释放锁pthread_mutex_unlock(&mutexA),pthread_mutex_unlock(&mutexB)。相当于呢我们线程要加两把锁。我们这样写没有问题, 有同学会说,有时候显示器打印出来的东西是乱的,同学们为什么是乱的呢,这还用说吗,原因很简单,因为当所有的线程或进程向显示器打印的时候,打印在一个显示器下,那就证明多个线程看到的是同一个显示器,Linux下一切皆文件,大家既然往同一个文件里打,那么同学们这种多线程,多进程向显示器打印的时候,显示器就是临界资源,所以你cout打印这部分访问显示器操作,就叫做访问临界区,所以以前你没有保护,没有访问控制,那打印的时候当然是乱的。所以呢给大家提个醒。
》线程2呢也要去申请锁, 它也是同样的跟线程1那样去干。我们编译运行一下,此时他们就互相竞争式的打印,这没有问题。因为大家都知道,这种申请锁的方式呢,理论上只要把第一把锁拿到了,第二个锁一定是申请得到的,但架不住有人写代码有一个startRoutineA和B锁反过来一下。此时就相当于线程1呢先申请A锁,线程2呢先申请B锁,申请完之后呢紧接着就会出现什么问题呢,申请完之后就会出现线程1申请了A锁,线程2申请了B锁,然后他们各自申请了第一把锁就要去申请第二把锁,但是此时两个各拿着对方的锁,自己一人保持着一把锁,互相申请对方的锁时,因为自己要的锁已经被对方拿了,我们默认的这个pthread_mutex锁呢又是挂起等待,那么就出现了一个现象,线程1申请B锁,但没有就被挂起了,线程2同样申请不到A锁也被挂起了。但是挂就挂吧,但是各自都抱着对方锁挂起,说白了就是线程被阻塞住了,操作系统里呢,我们线程直接相当于阻塞或挂起了。那么其中呢互相都不可能申请到对方的锁了,因为没有人释放,最后这个僵局无法打破,进而造城死锁问题。这样写的话呢,出现死锁的概率也不是很大,有,但是不大,但若是sleep(1)上1秒那就会大大发生,我们再编译运行就发现出现卡住的情况了,这就是死锁。
》说两个问题,有人说怎么可能会有这种情况,怎么可能会出现你这种情况,会不会太弱智了。同学们,问一个问题,在学C语言的时候,这个指针越界了,这个数组越界了,当我们拿着代码片段一想确实是这样,然后你一看怎么会有这么弱智的错误呢,可是你后面会不会有可能犯,你后面该怎么犯还是怎么犯,因为你刚开始学C 语言的时候,你的写代码的场景比较简单, 如果项目出现了几千行几百行,一个指针被定义的地方 ,在使用的时候已经是1000行之外了,所以那个野指针的问题就被隐藏的很深。所以曾经认为很简单的问题,其实问题本身并不难,解决问题也不难,实际上是因为场景变得更复杂,你要定位问题变得很困难。同样的死锁也是一样,你不要以为死锁怎么这么弱智,那是因为场景简单,如果我们现在几个线程并发去访问,然后呢我们线程当中执行的代码,访问的资源,一会儿这,一会儿那,出现了大量我们对应的锁的申请和释放,那么死锁的情况就大大的产生了。
》第二个问题,同学们,关于死锁的线程我们也看到了, 就是互相较劲,不释放对方的资源,其实呢我们对应的死锁问题呢在我们对应的系统和各种场景里面,它是一个挺常见的线程,但是同学们,我们记住了,解决死锁最好的方法就是尽量不要用到锁,虽然是给大家讲多线程,是不是我们未来编码的时候必须得用多线程呢,不一定,我们也不是一个讲什么东西,就把什么东西吹的天花乱醉,多线程有多线程的优势,也有它的劣势,所以我们一定是在合适的时候用合适的方法。那么未来在面临一种情况的时候,如果非得让你去加锁,你首先要想到的不是怎么加锁,而是可不可以不加锁,能理解吧。所以我们不管后来谁叫你去加锁什么的,你记住一句话能不加锁的,尽量不要去加锁。
》因为对我们来讲呢,加锁有性能的下降的问题;二还有问题潜在的问题。你可不可以通过想一些通过不加锁的方案来解决问题,这永远都是最优解。

void *startRoutine1(void *args)
{
    while (true)
    {
        pthread_mutex_lock(&mutexA);
        sleep(1);
        pthread_mutex_lock(&mutexB);

        cout << "我是线程1,我的tid: " << pthread_self() << endl;

        pthread_mutex_unlock(&mutexA);
        pthread_mutex_unlock(&mutexB);
    }
}
void *startRoutine2(void *args) 
{
    while (true)
    {
    	/*pthread_mutex_lock(&mutexA);
    	  pthread_mutex_lock(&mutexB);*/-----➡️正常情况
        pthread_mutex_lock(&mutexB);
        sleep(1);
        pthread_mutex_lock(&mutexA);

        cout << "我是线程2, 我的tid: " << pthread_self() << endl;

        pthread_mutex_unlock(&mutexB);
        pthread_mutex_unlock(&mutexA);
    }
}

int main()
{
    pthread_t t1, t2;

    pthread_create(&t1, nullptr, startRoutine1, nullptr);
    pthread_create(&t2, nullptr, startRoutine2, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);

    return 0;
}

✳️再来谈谈死锁:
首先呢关于死锁问题呢,上面也说了,它很常见,但是我们也说了,我们虽然在讲并发,在讲多线程,但实际上尽量你后面遇到问题的时候,不要只想着多线程,也不要只想着多线程和多进程去解决,有时候单进程不一定花时间。像你们听说的redis,那么redis就是一个单进程的程序,它就是没有用多线程,它是用到了多进程,但是用多进程的时候是实现它的数据的问题。实际上大部分都是单进程,为什么呢,redis在设计的时候,为什么会考量你所给我讲的单进程呢,原因很简单,因为单进程在内部实现的时候不需要考虑并发问题。所以我们要对他有一个正确的认识。
》下面我们不考虑其他,就谈死锁问题,死锁既然已经发生了,那么我们应该怎么去正确的看待死锁呢。在这里我想先和同学们谈两个问题:第一,一把锁有没有可能出现死锁的问题呢?我们用代码来演示一下,我们创建了2个线程,和定义了一把锁。如果我们脑子抽了一下呢,多写了一行加锁呢?此时我们运行代码,发现卡在了那里,确实出现了死锁的情况。有人会说,这不属于代码问题吗?对的,死锁也是代码问题呀。有人说,我怎么可能会犯这种错误,我不可能这么弱智,你确定吗?有时候同学在写解锁的时候unlock不小心写成了lock,这很常见。同学们记住好了,死锁本来就是你代码有问题。我们常规的死锁和一般的死锁都见过,那么下面呢,我们再来谈谈死锁对应的条件。

✳️死锁对应的条件:
同学们我们为什么刚刚死锁之后,代码就不推进了呢?原因很简单,因为死锁,我们观察到的很明显的现象就是,互相谁都不让谁,拿着一部分资源我就是不释放。所以呢死锁产生一般会有4个必要条件:
》你们以前也学过数学,知道充分条件和必要条件,必要条件大概意思就是,一个事情发生了,一定需要具备的某些条件。就好比有一个凶手案,嫌疑人为了证明自己不是凶手,首先要做的就是,我有不在场证据,为什么呢。因为作为一个嫌疑人,一个犯了案子的人,他为什么犯案呢,一个必要条件就是一定去了现场,它就是当时做事情的人。所以我们只要攻破了这个必要条件就不会落在头上。同样的,我们将来要谈论的4个必要条件就是,只要死锁产生了,这4个条件都必须都要满足,如果没有一个满足,那么死锁便不成立。所以换句话说呢, 我们所对应的死锁的概念呢。我们首先要谈的就是死锁的必要条件。

·互斥条件:一个资源每次只能被一个执行流使用(一切的一切都是因为你有互斥的存在而导致我死锁了,互斥的存在导致我死锁了,这是根本原因,可是同学们这不就是你用锁的原因吗,你用锁了,所以你产生了死锁,这不正常吗,所以互斥条件很好理解。产生死锁一定是互斥,其中有一个原因就是因为i互斥。)
·请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放(请求与保持意思很简单,就是我要请求你的 ,我还要保持我的,这就叫做请求与保持 ,下面我们以我们上面代码罗列的两种死锁情况。我们之前的那一种,我请求对方的锁,而我又保持自己的锁,也就是我拿着我的锁的同时还要着对方的锁,这就叫做请求与保持。再比如我们上面说的一把锁怎么会死锁呢?那是因为,你第一次执行加锁的操作,你拿到了锁,后面紧跟着又去申请锁了,当你再一次去申请锁的时候,那么你身上锁你拿着,又跑去要,此时呢你申请,那你一定会失败,失败你就会被阻塞或者说是挂起,这里的挂起和操作系统说的挂起概念不太一样。你当前线程被阻塞挂起,也就是意味着你是抱着锁被挂起的,可是同学们,你把锁抱着挂起了,以后有没有人释放锁呢,没有呀,因为锁你拿着呢,没有人再能释放锁了,那怎么办呢,不能怎么办。此时只能终止程序,没人能唤醒你了。你拿着一把锁,还再次的再要这把锁,这就是基于一把锁的请求与保持。)
·不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺(就比如说我们写的线程1和线程2互相竞争A、B锁,他们两个呢,线程1拿A锁,线程2拿B锁,然后两个互相申请对方的锁。因为有互斥的存在,我们必须得申请。第二我们有请求保持的条件,所以我们得拿着我们的锁像你去要,请求与保持谈的是,我要找你要,但我自己不释放我的,这叫做请求与保持。保持就是我拿着我的,我也不释放。然后,但是如果你不给我释放,我把你揍一顿,把锁拿过来,这叫做剥夺你的锁。可是呢,我们默认的锁,它呢是不剥夺条件,也就是说呢,一个线程不能因为自己的优先级问题,权重问题而导致自己去强制剥夺对方的锁。那么此时这种条件,叫做不剥夺。如果能剥夺了, 我拿着你的锁,你拿着我的锁,互相申请对方的锁,我们因为互斥与请求保护的存在,我们在申请对方的锁,可是呢,有一个裁判,裁判说,两个人不能打架,那么此时就只能死锁了。但如果说我允许你们两个打架,你们两个去竞争,那既然锁被你俩申请,你们两互相要对方的锁,怎么办呢,你们两就互相的看谁优先级更高,谁优先级更高就可以抢占对方的锁。实际上说白了就是强制让对方把锁释放掉。所以呢,这里就叫做不剥夺条件,所以呢,因为有不剥夺的条件存在,没有办法和对方展开竞争,没办法去要对方的锁。所以没办法,那就只能是死锁了。)
·循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系(什么意思呢,意思就是说,我们互斥了,我们也请求保持,我们也不剥夺,但是呢,我要是不申请你的锁,你也申请我的锁,我们两个没有形成环路问题,就比如说,线程1申请到了A锁,他要去竞争线程2的B锁。反过来,线程2要去竞争线程1的A锁。所以呢,双方在各自占有一个锁之后,还互相竞争着对方的锁,你要我的,我要你的,这就叫做环路情况。所以我们形成了环路等待,才导致我们造成了所谓的死锁。换而言之,若干执行流形成从头到尾相互衔接的循环的等待资源的关系,我们就称之为循环等待条件。)

换而言之,死锁产生一定会有这四大要素,分别叫做互斥、请求与保持、不剥夺、循化等待。换句话说呢,我们解决死锁的方案有哪些呢,
》避免死锁的第一种方案,破坏死锁的4个必要条件,也就是说呢,首先你分析下这4个条件,什么互斥条件,像这种条件呢,大概率我们没有办法,是不能取消互斥条件的,如果非得取消,那就是不用锁。所以这叫做什么呢,这叫做对我们来讲,互斥条件我们没有办法去保证。
》第二个呢就是,请求与保持。请求与保持,可以去处理它,我拿着我的还要去要你的,这种问题呢,可以去破坏吗?那么答案是可以的。怎么去处理呢,我要的时候,不要一直去要。比如说我可以要一次要两次,我要不到了,我就放弃了。换句话说,放弃除了我不要,还有一个就是,我们叫做,我可以给你要一次两次之后呢,你不给我,你不给我怎么办,那我就不要了,并且我把自己的锁也释放掉。那么这个时候,我们就可以让被人去申请我们所对应的锁了。这个就可以叫做我们所对应的请求与保持对应的一个的条件破坏。
》再下来呢,还有一个就是不剥夺。不剥夺呢就是我不抢你的,破坏这个条件的话,就是允许抢占,那么这种情况也是可以的。
》再下来呢,就是我们循环等待条件,环路等待呢就是,A要B的,B要A的。我们也可以尽量的去破坏这个条件。
》那么其中对我们来讲呢,避免死锁,只要我们破坏上述条件当中的任意一条,我们就可以避免死锁。这是我们操作系统层面上的概念。
》再下来就是呢,比如你说的,循环等待问题,你怎么去破坏他们环路等待的问题 。那么我们在编码上面的建议,比如说呢,你加锁的时候,顺序要保持一致。加锁的时候顺序保持一致就相当于,我们不要出现上面的情况,即一个线程先申请A锁,另一个线程先申请B锁,不要出现这种交叉的情况。那么而是什么呢,而是要申请A,申请B,另一个线程也是按这种顺序申请。你们要同时申请两把锁可以,但是申请顺序要保持一致。所以对我们来讲,这叫什么呢,这叫做保证加锁顺序是否一致。其二呢就是,要避免未释放锁的情况,也就是说,我们未来写临界区的时候,对临界区做保护,必须要保证你那个锁,用完之后尽快把锁释放掉。把你自己加锁的粒度变得更细了,那么这个时候,出现死锁的概率就大大的降低了。第三个呢,就是每次使用临界资源的时候,我们现在还没有太遇到,分配使用临界资源的时候,不要需要的时候给它分配一点,每次都加锁,那这样的话,增加了使用锁的频率,带来了结果就是,有较大概率产生死锁。那怎么办呢?那我们尽量能就一次就把它分配好,分配好你就去用。这就叫做我们资源学一次分配

对应的呢,我们也有一些死锁检查的算法。可以看看,没兴趣就算了。

我们一般来讲呢,pthrad_mutex_lock()是挂起等待锁,而pthread_mutex_trylock()函数呢,它会返回0,如果锁呢,已经被别人使用了。如果申请锁失败了,它的错误码会返回。注意她返回的时候呢,错误码表明错误的原因。其中trylock()它是一个,同学们可以理解成叫做,它是一个非阻塞的,当我们申请锁的时候,申请失败了,那么他会给我们立即返回错误码,可以通过返回的值得到错误的原因。所以当我们使用它的时候,可以用循环去执行,但是同学们,一旦我们打循环去申请的话呢,那么我们对应的就可以在循环之前做一些判断。做一些我们包括对代码的编写,比如说检查这个锁是不是别人也在申请,别人有没有释放等等。这个呢大家可以下来去试试。

pthread_mutex_t mutex = PHTREAD_MUTEX_INITALIZER;
void *startRoutine(void *args)
{
    while (true)
    {
    	pthread_mutex_lock(&mutex);
    	//pthread_mutex_lock(&mutex);//脑抽了一下,多写了一行加锁         cout << "我是线程,我的tid: " << pthread_self() << endl;
    }
}


int main()
{
    pthread_t t1, t2;

    pthread_create(&t1, nullptr, startRoutine, nullptr);
    pthread_create(&t2, nullptr, startRoutine, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);

    return 0;
}

线程同步

✳️下面我们就要进入,同步概念与竞态条件。
我们在讲一个故事帮助大家去理解,线程同步时遇到的一些问题。 首先我要问同学们第一个问题是,❓线程互斥,它是对的,但是它合理吗?注意我们谈合理性,一定是和场景有关,这里它合理吗,指的是他在任何地方它都是合理的吗?什么意思呢,意思就是说,线程互斥它能保证抢占我们对应的锁,抢占是OK的,抢占我们临界资源是没有问题的。你是对的,但你一定是合理的吗?
》下面我们讲一个小故事:同学们, 今天呢比如我们去食堂里吃饭,去吃饭的时候呢,阿姨告诉你们说,你们以后打饭的时候得竞争,你们竞争的时候呢,谁能争过其他人,我再把饭打给他。其中呢,你和你的朋友到了餐厅,然后问那个打饭大妈,现在可以打饭吗?它不理你,因为有其他优先级更高的过来了,她此时就一直进行打饭,现在虽然你和朋友来的早,但是大家互相抢,比的谁块头大,快头小的抢不过快头大的,女生抢不过男生,其中对我们来讲,大妈互斥的规则就是,我任何时候都只能给一个人打饭,你们去抢吧,我给谁打饭说了不算,你们互相竞争,所以最终呢,你因为太弱小,而导致争不过别人,争不过别人,又没有人可怜你,那么没办法,此时陆陆续续不断有块头比你大的人在餐厅里打到饭,但你就是打不到饭。同学们,那它错了吗?我们大妈的打饭制度错了吗?答案是:没错!为什么呢,因为它确实一次只能给一个人打。但是它合理吗?这是第一个例子,再给大家来一个例子。
》就比如说呢,我们现在有一个图书管,那么这图书馆呢有一个奇怪的规定,什么奇怪的规定呢,就是这个图书馆的自习室呢,它一次只允许有一个人来自习,那么在自习室门口有一串钥匙,这串钥匙在外面能打开,对应的自习室,在里面能关闭反锁自习室。任何时刻我们只允许一个人到自习室去自习。同学们,那么此时呢,你很早就跑过去了, 你可以理解成,很合理的,你每天早上呢来的是最早的,从墙上把钥匙取下把门打开进去自习,在里面自习了,比如3分钟,你就出去了,出去了此时,钥匙在你身上,然后把门一反锁,那么即便是你人不在后续再来的人想进去,对不起,你进不去。因为其他人没钥匙,因为只有拿钥匙的人,它才能够进行我们所对应的叫做进到自习室里面。这是这样的场景,所以呢,你很早就去了,拿到墙上的钥匙,进到自习室里面,很欢快的在自习,自习了一会儿呢你觉得没意思,想出去玩会儿,你就把门反锁,钥匙挂在墙上。钥匙挂墙上,你这一想,我不能这么荒废,然后呢你迅速从墙上把钥匙又拿走了,又把门打开进到自习室自习。你这次坚持了一小时,然后呢,站在上帝视角,陆陆续续有其他同学来了,其他同学一看,当前自习室有人,所以其他人呢,就三五成群等着呢。然后呢,你刚一出来,大家就骚动,你再把钥匙挂墙上的时候,一瞬间有一大批的人抢钥匙,可是在其他还没有冲上来之前,你又想我还是不能这么轻易的走,我还想自习一下,所以此时你又把墙上的钥匙把门打开自习,此时又过了两分钟,你实在是看不进去了,出去了,你把门一锁,刚把钥匙往墙上放,你又想自习,又拿了下来去自习。同学们,因为你离墙上的钥匙都最近,比人都抢不过你,抢不过你怎么办呢,别人只能够在外面三五成群说着你一个人占着自习室。你才不管他们呢,因为你符合游戏规则,所以你每次都能拿到钥匙去自习。今天一早上什么都没看,就开门和关门,把钥匙从墙上拿上来拿下去。同学们,这错了吗?答案是:没错!因为你遵守了游戏规则,因为我们说清楚了,叫做一间自习室只能有一个人自习,但他并没有规定必须是哪一个人进,它是对的,但他不一定合理。
》其中我们以第二例子再为例,其中你自己在自习室,中间你自己出去了,你把门一关,去上厕所去了,这叫做你干什么去了?这叫做你被操作系统切换走了。切换走了,可不是你一个人就走了,你把钥匙装在口袋里拿走了,你被切走的时候是抱着锁走的,那么其他人也进不去。第二个,你进入自习室自习,出来,把钥匙往墙上一挂,过一会儿你又去拿了,因为你竞争能力很强,别人离锁比较远,那么此时你把钥匙挂墙上,自己又拿走,长时间以往下去,因为你的竞争能力比较强,而导致其他线程长时间得不到对应的图书资源,或者打饭资源。那么这种情况叫做,我们多执行流,执行情况下的饥饿问题。
》**我们互斥是对的,但它不一定合理!因为互斥可能导致饥饿问题,即一个执行流长时间得不到某种资源。**你就认为它优 先级太低了,它的竞争锁的能力太差了,那么此时这就叫做我们对应的饥饿问题。
》那么其中我们在例子一当中,打饭的例子,你抢不过别人,你就饥饿了;第二个例子,图书馆的例子,你排队排不过别人,来的没别人早,抢不过别人,就造成了你饥饿问题。这叫做什么呢,这叫做它是对的,但是它不合理。但是呢互斥这种情况,也不像同学们想的那样,一定会导致饥饿的问题。互斥更多的适合于在一些突发情况,我们要进行无任何优先级的竞争,比如说是抢票,地位对等的情况下,用互斥锁,互斥的方式来竞争也是OK的,不代表它不能用,而是OK的,只不过得挑场合。那么其中,我们该怎么解决你所谓的饥饿问题呢?
》所以在我们的第二个游戏的规则之下,我们要加一个规则。要求呢,一个人在图书馆里自习,自习能保证没有人能够进到我们临界资源访问,但是,我加了一个规则。当任何一个人自习完毕时,把钥匙挂在墙上,对不起,你不能再立即申请这把锁了,如果你要再申请,你必须得在后面排队去,其他以前三四成群,你们一个个的把队排好,把队排好之后,当自习室的人出来把钥匙挂墙上的人,你不能立马申请锁,而应该是排到队列的尾部,等其他人申请完资源之后,然后你才能再去申请。所以我们一般在保证互斥的前提条件下,我们多做一个工作,让多个人访问某种资源具备一定的顺序性,这种特性,我们叫做同步!
》所以呢,什么叫做同步呢,**在保证临界资源安全的前提下(互斥等),让线程访问某种资源具有一定的顺序性,我们称之为同步!**所以互斥与同步有什么关系呢,他们两个不是对立的关系,而是互相补充的关系,也就是互斥保证线程安全,同步是在线程安全前提下,同步不一定需要互斥,但是在有些情况下,需要保证线程安全的情况下,可能你要用到互斥。一般呢我们要保证多个线程具有一定的访问顺序性的概念,我们就叫做同步。我们为什么要保证顺序性呢?我吃饱了撑的,我一定要多线程保证顺序性呢?
》原因有两点,第一点,防止饥饿;第二点,线程协同。也就是说呢,只要我们能够让多个线程能够按特定的顺序,它能按特定的顺序,它就能按找我要求的特定顺序,那么此时我就能控制线程,让线程按找我的要求去执行对应的代码。所以我们说了,同步的问题,它呢,它是解决什么问题呢?它是解决合理性问题。所以同学们,线程互斥的情况是对的吗?一定是对的,因为它没出错,但是它有时候不合理!所以我们要用同步来解决不合理性的问题。
》同学们如上就是我们,同步概念的抛出。既然我们已经知道了,这里叫做我们的同步,它的的概念之后,然后,我们要做的当然就是,要怎么保证同步呢? 下面呢,我们来谈两个概念,叫做条件变量,它就是同步的具体一种方案。
在这里插入图片描述
✳️同步概念:
我们来谈两个概念,第一个同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。(那么其中,也有一个概念叫做竞态条件)
✳️竞态条件:
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解。(但是呢同学们,时序问题导致程序异常,我们称作竞态条件,那么举个例子,比如说我们上面写了一个互斥锁,两个线程呢,一个申请A锁,一个申请B锁,我们的代码有问题,但是你代码不加sleep(1)的话,不一定会出问题,他可能运行上个十几天才出现一次,它出问题呢,完完全全和我们CPU调度顺序有关系。有时候你的代码没有明显的问题,但是因为调度的问题而导致程序出问题了,这就叫做,竞态条件。竞态条件的问题呢,我们后面写代码时遇到了到时候再说。)

✳️条件变量
当我们把概念抛出来了,我们再看看条件变量,那么条件变量这个问题呢,给大家说一下,当我们在Linux当中呢,抛出了同步的概念,那请问我们该如何的去完成同步呢。所以Linux提供了一种让我们去同步的一种重要机制,叫做条件变量。条件变量是Linux当中最常用的,没有之一,最常用的线程同步的策略,当一个线程互斥的访问某一个变量时,他可能发现在其他线程改变之前,他什么都做不了。比如说某个线程访问某个队列,发现队列为空,它就只能等待,只有当其他线程向队列里添加节点的时候,我们这个时候才能让其他线程去拿,这种情况我们就要用到条件变量。不过呢,干巴巴的去讲条件变量,对同学们来讲呢,就是一种煎熬。所以呢,关于条件变量呢,我想给同学们怎么讲呢,我想把我们的课件内容大散,给大家分三步来讲。
》第一步,关于条件变量呢,我们说再多都没用,我们的第一步呢是想带大家,做第一件工作,直接认识接口,然后呢给大家写一个demo代码----仅仅是见一下猪跑–初步认识一下条件变量。也就是说呢我们见一见,这个多线程之间该怎么个同步法对吧,这是其一。
》第二个呢,我想给大家讲一个,生产者和消费者模型-----它呢我们叫做什么,叫做同步与互斥 最典型的应用场景。也就是说呢让同学们知道,同步与互斥,我们一会儿给大家用模型。顺便给大家重新认识一下条件变量
》然后第三步,正式的编写代码,实现一个基于阻塞队列的生产者和消费者模型,我们的任务 很艰巨。

✳️第一步
我们进入第一步,叫做认识接口还有概念。同学们我们再想想,我们在进行同步的时候,一定是让线程产生一定的顺序性,对不对。我们在同步期间,其他线程正在运行的时候,比如说某个线程占有了锁,它在进行使用这个锁的时候,其他线程也想去访问这个临界资源,那么此时,为什么之前是所有的线程在竞争这把锁呢? 原因是因为,我们没有一种机制,每一个人,能把线程管理起来,让所有线程有效的进行锁的竞争,所以个锁就绪了,大家就一哄而上,就去抢了。但是对我们来讲呢,条件变量呢它是一种,我们在代码当中引入的一种策略。条件变量呢决定我们什么时候叫醒一个线程,以前呢,我们只要有锁了,大家都被,你可以理解成,所有线程可能都被叫醒,大家都参与竞争,去抢,谁抢成了就是谁的,机制完全是调度器决定的。但是呢,对我们来讲呢,我们这个条件变量的引入它可以让我们主动的去唤醒我们的,在我们队列维护当中的某一个线程,或者一批线程。所以对我们来讲,**条件变量呢,**它呢,**把我们以前,唤醒线程,由系统自动唤醒,转化成让程序员自己唤醒。**也就是说,程序员呢,你自己可以去控制你唤醒哪个线程。那么这就是条件变量引入的一个最大特点。
》至于呢,它更多的细节呢,我们从来没见过,所以我们先见见再来理解一下它哈,所以接下来呢,关于条件变量,我先让大家认识接口,然后再讲它的demo代码。我们在Linux系统当中呢,我们可以使用称之为,条件变量来进行我们所对应的,可以称之为控制我们线程。
》怎么用呢,首先,条件变量的类型叫做 “pthread_cond_t”。pthread_cond_t cons;这就是传说中的条件变量,当然条件变量里面呢,它也是有锁的,包括里面还有等待的人,算是它用户层结构,但是也不需要考虑它,只要知道它是一种数据类型就行了,这是其一。其二呢,我们要使用条件变量的时候,跟我们之前的互斥锁非常像。“pthread_cond_init()”函数,我们要初始化条件变量,可以有两种方式,一种是静态或全局的条件变量直接用宏PTHREAD_COND_INITIALIZER来初始化,和之前的mutex一摸一样。第二种,局部的条件变量可以使用pthread_cond_init()函数来初始化,第一个参数,对特定的条件变量做初始化;第二个参数,条件变量的属性不管,设为nullptr。然后pthread_cond_destroy()函数式最终不要这个条件变量了,可以对其进行销毁。同学们,类似的接口我们讲过吗?类似的接口我们都说过。 也不谈上说过,只不过跟互斥锁非常像。所以人家叫做POSIX标准,因为对应的标准呢,它就规定了我们使用这些接口的情况。
》我们这个条件变量有个典型的应用,叫做“pthread_cond_wait()”函数,它这里的wait呢,也是一直进行wait阻塞等待。等待什么意思呢,第一个参数,就是你在特定的条件变量下去等;第二个参数有意思,第二个参数叫做,互斥锁。我呢也还是前期,给大家直接出结论,稍后我们再慢慢感受哈。
》第一个结论,**我们的条件变量要和mutex互斥锁,一并使用!也就是说呢,锁可以独立使用,但条件变量呢必须得跟互斥锁去使用,所以pthread_cond_wait()的第个参数是mutex,这也能理解,这个就是我们的互斥锁。
》然后再下来呢就是pthread_cond_timedwait()函数,多带了一个time,什么意思呢,就是你可以在条件变量下等,等的时候呢,你可以设定等待时间,比如说你可等待5秒,5秒之后呢还是没有能够让我醒来,那么我这个线程呢就自己醒来了,这个叫做可以设定超时时间,课堂上我们暂时不用它。当然你想要用,也比较简单,这个struct timespec是个数据类型,这个类型呢你可以设定秒或者纳秒。
》再下来呢,我们还有一个线程当中比较特殊的接口,叫做“pthread_cond_signal()”函数,这个地方和我们以前讲的那个,叫做进程信号是不一样的,这个signal叫做唤醒指定的线程,唤醒指定线呢,那么唤醒什么指定线程呢?唤醒在该条件变量下等的那个线程。当然大家也看到了另一个函数是pthread_cond_broadcast()函数,其中broadcast呢就是广播的意思,说白了就是唤醒在指定条件变量下等待的所有的线程。而signal是唤醒一个哈。
》同学们,刚刚快速的给大家说了4个接口,分别是init、destroy、wait、signal函数 ,broadcast我们最欧再说。我知道这些接口的细节大家还不懂,没关系,下面呢给大家写一个小小的demo,写一个什么demo呢,我们就想让我们的代码呢,线程直接被控制一下,课件是那种写的,但我想换种写法,新创建上比如说3个线程,然后我想让我们的主线程,再去唤醒我们指定的线程,来怎么做到的呢,下面给大家快速的写一个代码。帮助同学们理解一下。
》我们就用全局的条件变量吧,所以定义一个全局的条件变量;pthread_cond_t cond,这是其一;其二呢,根据你刚刚给我说,条件变量的使用要配合互斥锁,所以呢,我们再定义一个互斥锁,也定义成全局的, pthread_mutex_t mutex;下面呢我想创建3个线程,则先定义出3个线程,即pthread_t t1,t2,t3;然后调用pthread_create(&t1, nullptr, waitCommand,nullptr),我们要让线程等待我的指令,所以回调函数是waitCommand()函数,最后一个参数我们也就不传了,后面我们用其他线程接口来获取。然后t2、t3和t1类似就行。 然后紧接着就是调用pthread_join(&t1,nullptr)等待新线程退出。下面我们想干什么呢,我们想通过我们的主线程,来控制让这3个线程分别去执行,我想让你们调哪一个,你们就按顺序调,我们点名去调呢,现在在条件变量里做不到,但是我可以让你们按照特定的顺序去帮我做事情。
》怎么办呢,这里开始编写void
waitCommand(void
args)函数,里面whiel循环,此时呢这3个被创建出来的线程都会去调用这个函数,其中对我们而言呢,你们三个都要去调用我们对应的接口的前提是,我们得把锁和条件变量初始化。我们也不废话,给大家说一下,锁我们现在有了,条件变量我们也有了,现在要对cond条件变量做初始化,怎么办呢,我们可以调用宏来初始化,也可以调用初始化函数初始化,我们就都用用,mutex锁我们用宏来初始化,cond条件变量我们调用pthread_cond_inti()函数来初始化,即pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;pthread_cond_init(&cond, nullptr);此时呢锁和条件变量都初始化完了。然后我们用完锁和条件变量之后要记得销毁释放!那就要调用pthread_mutex_destroy(&mutex),pthread_cond_destroy(&cond);相当于我们在3个线程这里执行的时候,这3个线程做什么呢,这3个线程当前所做的工作,我们可以称之为,全部都相当于是执行的是我们waitCommand()等待指令。
》下面我们在while()循环里干什么呢。直接调用pthrad_cond_wait() 函数,3个线程谁先被创建,谁先被执行,创建的话肯定是t1先被创建,但是谁先去执行完去是随机的,这3个线程,你们接下来要执行后续的代码,假设呢我们现在要执行一个代码,这个代码我们称之为,cout << "thread id: " << pthread_self() << “run…” << endl;所以现在的问题是什么呢,就是我们现在呢,在让每一个线程执行的时候,它们都得先执行pthread_cond_wait()函数,其实就是让对应的线程进行等待,等待被唤醒。所以这个函数参数里面呢,我们可以看看刚刚的接口介绍,第一个参数,就是你想让对应线程在哪一个条件变量下去等,我们就&cond,第二个参数,我们条件变量通常和互斥锁混合使用,必须得用锁。但今天,我们目前举的例子还没有用到锁,注意,没有用到这个锁,你还必须得有。所以这儿呢,你可以理解成,是用了,但是我只是用它做了一个占位符,因为关于锁的出场是要有场景的,所以就要&mutex传参。说清楚,我们今天呢,就相当于每个线程被创建出来,不会往后去运行,不会往后运行那干什么呢,它在等我们主线程后来呢给他来唤醒每个线程,而其中呢,我们这个互斥锁当前不用,但是接口需要,所以我们需要先留下来。有人说,我看你课后写的代码, 这个代码里面的锁没用呀,我们说过了,我们暂时不考虑,一会儿有对应的场景了,你就知道干什么了。所以呢,对我们来讲,此时我们就有了,pthread_cond_wait()函数,每个线程都不会去执行下面的cout打印语句。那么在pthread_cond_wait()要怎么样呢,只要执行了下面的代码,那就证明某一种条件不就绪(现在还没有场景,也就是说我们现在还并不清楚,但你要知道,只要我调用了pthread_cond_wait()接口,执行这个函数的线程就会被阻塞住),要我这个线程等待 。
》我为什么,想把这个接口给大家一说呢,想让大家先看个场景,给大家看看应用,代码先用起来,那么你你以前有没有写过类似的代码,就是一个执行流呢,它在执行的时候,会因为某一个条件不满足而阻塞在那里,而后期当有事件发生的时候,我们可以通过一个执行流去控制另一个执行流呢?我们以前写过没,我们之前是不是还写过用父进程来控制子进程的管道的相关代码。同学们,每一个子进程呢,通过读取管道,当没有数据的时候,都会阻塞在那里,对不对,这就是控制它。所以你可以理解成,当我们执行到pthread_cond_wait()这个函数,那么每个线程就要卡在这里,就等着我主线程去叫醒你,我叫你的时候你再醒来,一个个来 。同学们,可是我有3个线程呀,3个线程同时调用一个函数怎么办呢?三个线程,都会在条件变量下,(也就是在我们原生线程库当中)在该条件变量下进行排队。也就是说这3个线程都给它执行进去,就会进行排队。既然大家都排好队了,下面我唤醒一个线程做一件事情,来,你下一个来做一件事情,在下一个来做另一件事情。那么此时我们可以在主线程来一个while循环来控制新线程。
》那怎么去控制呢?我来给大家写一个好玩的程序,我们来一个char n = ‘a’;当然你也可以自动化起来,自动控制。我们再来一个cout << "请输入你的command: " 然后怎么办呢,然后cin >> n;同学们记住了,cin和cout两个人交叉使用的时候,缓冲区会强制刷新,也就是cout我们执行了,下一个就是cin,所以系统呢,cout执行流呢会立即将数据刷出来。我们现在command给你的是什么呢,n表示继续,q表示退出 .接来下你输入n,我们对n做判断。我们来测试一下代码,首先我们要看到3个线程跑起来,在phtread_cond_wait()函数那里等待,然后主线程什么都没做,但是它可以获取输入输出。我们运行起来后,然后xshell输入#ps -aL | grep mythread查看我们对线程。现在我们改一下,我们输入n的话,不再是打印出aaaaaaa了,而是唤醒在该条件变量下等待的线程,怎么唤醒呢?你刚刚不是和我讲过吗?我们上面说的pthread_cond_signal()函数接口,就是唤醒在某一个条件变量下等待的线程,唤醒一个线程,即pthread_cond_signal(&cond);就唤醒了某个线程。
》一旦我唤醒了某个线程运行,唤醒完之后我接着循环再次输入,我们就应该看到,我每次按一个n,就有一个线程听我的话,执行从pthread_cond_wait()返回后一下的代码。我们再编译代码并运行,然后我们输入n,确实有打印cout的内容,因为我们在打印的时候,主线程在打,新线程也在打,它们两的信息在显示器上互相发生干扰所以就看到了他们的信息出现在一行上,这个我们能够理解,因为显示器是临界资源,我们没有对临界资源做保护,主线程在打,我也在打 ,我们可以让我们的代码sleep()一下,也就是说呢,主线程把命令输完了,你signal完了,你不要急着打印,你等一等让别人跑完,你再输入。
》我们输入q退出的时候,我发现这3货也不退出,所以我主线程跳出循环join等待3个新线程退出,那我们怎么办呢,我们可以做一个工作,当我们break出去之后,我们可以调用pthread_cancle()函数,这个我们前面讲过,是对特定线程发送取消请求,这样就能join成功。但是我们继续编译运行,发现我们q的时候还是卡在那里,我们不是cancle()了吗?请告诉我此时为什么没有反应呢?我不是cancle把你们这3货取消了吗?怎么会出现卡在那里的情况呢?主要原因是因为现在这3个线程呢,在你运行期间,他们每个线程都在做一件事情,就是打印完成之后,循环继续等待。 所以线程现在处于休眠状态,你现在发送取消请求,但人家没有醒过来。那么醒过来怎么办,那我们可以把他们叫醒。而我们的pthread_cond_signal()一次只能叫醒一个线程,唤醒的是一个线程,那么我们可以尝试一次唤醒所有的线程,把所有的线程全部幻想,我们称之为叫做pthread_cond_broadcast(&cond);唤醒所有在该条件变量下等待的线程。我们在回调函数中while循环里的if里面加上pthread_cond_broadcast()函数,然后我们在主线程内,给所有线程都发送了请求之后,再加上pthread_cond_broadcast(),把所有线程唤醒。就相当于,醒醒,你们已经被取消了,那3个线程在睡眠,领导说把那3个线程取消了,然后人事部说,醒醒,领导发话说把你们都取消了,你们几个退出吧。那到底能不能呢?我们输入n,发现3个线程都执行cout打印,我们输入q,发现执行了,但是发现好像也没有什么用 。
》说明我们现在的取消方案,在条件变量下使用呢,他还是不太合理,我们没办法去终止新线程,那怎么办呢?我们把能想的都想了,怎么办呢?此时有人说broadcast()放在cancle()的前面,先把你们这几个货叫醒,再发送,我们来试试看行不行。我们编译并运行,发现也没有什么用。那怎么办呢,好像取消不了新线程。那么在这waitCommand的场景下取消呢还是比较困难的。因为条件变量里面呢,本来就占有着我们的锁,所以取消的时候,我们没办法取消。所以这样的代码,我们只能用其他的写法。
》取消不了,没关系。反正我已经控制了你,让你们每一个线程按照我的要求去执行,那现在我想退出你们该怎么办呢?所以我们可以加一个,定义全局退出变量,即我们定义一个bool类型的变量,bool quit = false;保证这个变量,必须每一个线程能做检查,都必须在我们的内存当中做检查,则要用到volatile来修饰,即volatile bool quit = false;同学们见过volatile见过吗?我们是不是见过呀。然后我们将回调函数内的while判断条件,原来一直是true,现在改为“!quit”来判断是否继续循环了。
》所以如果不退出,我们就让新线程一直运行,那么此时quit变量就体现出来了控制在里面,然后呢quit也描述了线程运行的状态。我们quit为false的时候,你们几个新线程都在wait()那里等,当我们else的时候,既然都else了,说明什么呢,说明我们主线程准备让这几个新线程退出了,退出了怎么办,我们在else代码里面加一个,quit = true,就是说呢,让我们退出的标记为变成true。这个变量变成true了,但是这几个线程依旧是处于某种等待状态。那怎么办呢,break之后主线程退出while循环之后怎么办呢?我们就在while循环外面调用pthread_cond_broadcast(&cond)函数,将所有的新线程唤醒。只要走到这pthread_cond_broadcast(&cond),对应的几个线程,已经是全部相当于叫做,我们称之为叫做,因为醒来新线程会去执行一次wait()下面的代码,然后再去while循环判断,循环判断条件!quit为假,则会跳出循环,不再去执行wait()等待了。
》现在我们继续编译并运行,我们输入n,三个线程都运行,我们再q的话,我们看到还是没有完完全全退出。我们看到,我们输入q之后,当前quit标志位被设置为true了,然后我们再来进行把所有在该条件变量下等的线程呢,我们让它全部唤醒去执行一次cout打印,然后再去while(!quit)去判断发现条件不出会退出。现在我们编译运行只看到一个退出了。这个问题我们后面再说(在下面有🚩这个图标的地方就是解释该问题所在!)。
》我们改一改代码,怎么改呢?线程线程被调起来了,我们可以调用pthread_detach()函数,进行线程分离,即在回调函数呢加上,phtread_detach(phtread_self());分离掉之后呢,当我们想退出的时候,我们就不需要有phtread_join()函数了。当我们想退出的时候,主线程直接break就可以,至此我们就能够完成控制了。
》下面呢给大家再说一下,下面呢我们还可以再做一件事情,你自己可以试着再定义一个,就比如说,写一个叫做,typedef vector<function<void()>> funcs;此时我就定义了一个funcs,然后呢你们可以自己写一堆的方法,比如说void show(),我们干脆把funcs定义成一个全局的容器,那么我们就不要typedef了,这就相当于定义了一个方法集,然后怎么办呢,然后我们,再来一个Print()方法。这个方法呢可以是具体的某一个工作,所以接下来我们对应的方法呢就是show 和 print,每一个线程在唤醒自之前,要做的就是for循环,for(auto& f : funcs),然后我们可以调用方法,即f();换而言之呢,我们将来呢,我们实际在进行我们的主函数在输入n的时候,你首先可以让它去选择执行哪个方法,我们就一次load进全部把,即funcs.push_back(show);funcs.push_back(print);甚至我们可以funcs.push_back({cout << “你好世界!” << endl;});因为我们在push的时候呢,你可以对方法做管理,所有的方法你自己维护,维护好之后呢,线程被唤醒就会自己去执行方法。你可以给每个方法做一个编号,然后你输入几号选择哪一套方法你想运行,选择哪套方法运行其中就是向我们对应的funcs容器里面放一些方法,这些方法交给谁呢,你自己来定交给那个线程。所以,你有哪个方法,想要哪个线程去跑,你就把方法放进去,等线程醒来就会自动跑。
》上面呢,就相当于让大家见了见条件变量,最终线程是可以被控制的,那么我们今天呢,我不想cin呢,然后是不是就直接在唤醒某个线程的时候,我们直接让主线程sleep(1)1秒钟,然后让主线程pthread_cond_broadcast(),此时呢,我们是不是就可以让主线程一次就把所有线程唤醒 ,一秒钟唤醒一次,所以就相当于呢,内部我想让线程做什么事情,我们可以对自己的方法集做管理。这个就叫做条件变量是干什么的。来,我们刚刚回答了半天,最后只是想回答同学们第一个问题,让大家认识一下接口和demo代码,初步的见见猪跑,初步认识一下条件变量。条件变量的接口我们用到了5个,是init()、destroy()、wait()、signal()唤醒1个、broadcast()唤醒全部,我们也明显观察,我们用signal()一个一个的唤醒时,所有的线程是以队列的形式去排队的,当他在排队的时候,我们明显能看到,它的执行是有一定的顺序性的。当我们体会到这一点之后,就进入我们生产者和消费者模型。
》刚刚我们多线程broadcast退出的时候有点问题,我后面去看看,然后我把错误定位清楚了,然后我再发到群里,大家一起看看,我只是觉得之前代码不应该有这么一个问题的,到时候我们再看看。

pthread_cond_init():
 #include <pthread.h>

int pthread_cond_destroy(pthread_cond_t *cond);----➡️最终不要这个条件变量了,可以对其进行销毁
int pthread_cond_init(pthread_cond_t *restrict cond,
              const pthread_condattr_t *restrict attr);
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
函数介绍:我们要初始化条件变量

函数参数介绍:
🌟第一个参数 restrict cond:对特定的条件变量做初始化

🌟第二个参数 restrict attr:条件变量的属性不管,设为nullptr
pthread_cond_wait()#include <pthread.h>

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

pthread_cond_wait()函数介绍:
它这里的wait呢,也是一直进行wait阻塞等待。

pthread_cond_wait()参数介绍:
🌟第一个参数restrict cond:就是你在特定的条件变量下去等;
🌟第二个参数 restrict mutex:有意思,第二个参数叫做,互斥锁。
pthread_cond_signal()/ pthread_cond_broadcast()函数:
#include <pthread.h>
int pthread_cond_broadcast(pthread_cond_t *cond);

int pthread_cond_signal(pthread_cond_t *cond);

#include <iostream>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
#include <unistd.h>


using namespace std;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//互斥锁当前不用,但是接口需要,所以我们需要先留下来

pthread_cond_t cond;

volatile bool quit = false;
vector<function<void()>> funcs;

void Show()
{
	printf("Hello Show()"\n);
}

void Print()
{
	printf("Hello Print ()"\n);
}

void* waitCommand(void* args)
{
	phtread_detach(pthread_self());//线程分离
    while(!quit)
    {
    //只要执行了下面的代码,那就证明某一种条件不就绪(现在还没有场景,也就是说我们现在还并不清楚,但你要知道,只要我调用了pthread_cond_wait()接口,执行这个函数的线程就会被阻塞住),要我这个线程等待 。
   	//三个线程,都会在条件变量下,在该条件变量下进行排队。
		pthread_cond_wait()//让对应的线程进行等待,等待被唤醒
		for(auto& f : funcs) 
		{
			f();
		}
       	cout << "thread id: " << pthread_self() << "run..." << endl;

    }
    cout << "thread id: " << pthread_self() << "end" << endl;
}

int main()
{
	
	pthread_mutex_init(&mutex, nullptr);
    pthread_t t1,t2,t3;
    pthread_create(&t1, nullptr, waitCommand, nullptr);
    pthread_create(&t2, nullptr, waitCommand, nullptr);
    pthread_create(&t3, nullptr, waitCommand, nullptr);
    
 	while(true)
    {
        char n = 'a';
        cout << "请输入字符(n/q): ";
        cin >> n;
        if(n == 'n')/*pthread_cond_signal(&cond);*/
        			pthread_cond_broadcast(&cond);
        else 
        {
        	quit = true;
        	break;
        }
        
    }
    pthread_cond_broadcast(&cond);

	 /* pthread_cond_broadcast(&cond); 
    pthread_cancel(t1);
    pthread_cancel(t2);
    pthread_cancel(t3);
     pthread_cond_broadcast(&cond); */

	
   /*  pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr); */
    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond);

}

✳️生产者和消费者模型
刚刚上面我们把条件变量给大家见了见,见了见之后呢,然后给大家写了一个demo代码 ,这个demo代码的意思很明白,就是让同学们,能够自己手动的去控制线程,明显感觉到这些线程是会在我们的条件变量下,进行我们对应的排队的。排队的话,大家也明显能够感觉到一个问题,什么问题呢,就是首先我们之前互斥锁,在竞争锁的时候,它是一个线程疯狂的去抢占我们对应的锁资源,它是无序的,要不然也不会出现像我们之前你虽然挂起等待,但是所有线程只是在操作系统层面上挂起的,就是PCB放到我们的某些等待队列当中,但是一大批被唤醒的时候,他还是自己会抢,但是在条件变量下呢,你们得规规矩矩的一个个给我排好队,排好之后,然后我们在执行的时候,我们输入n,一路往下走的时候,我们发现他还是按顺序去执行的。刚刚的这个东西呢就叫做条件变量,刚刚只是初步的认识一下,下面我们进入主题,叫做生产者和消费者模型。
》同学们,生产者和消费者模型是一个什么东西呢,实际上呢,它是一种编程模型。我们经常在操作系统课本里,有老师告诉我们叫做生产者和消费者模型,但实际上呢,它确实是一种编程模型。什么意思呢,我给大家,举一个生活中的小例子,在生活当中我们有一种现实生活当中存在的一种东西,这个东西叫做超市。作为超市,在座的一位位同学呢叫做消费者,我们不知道生产者是谁,但我们永远知道消费者。我们经常跑到超市里面进行消费购买某种商品,同学们,那么我想问大家,你去超市购买商品的时候,你就是切切实实的消费者,当然有很多人。那么你买的这个商品是谁给你的呢?也就是说你是消费这个商品的,你把东西买了拿回去,那么是谁生产给你的呢?那么同学们,肯定会说,是超市生产给我的,因为超市就是负责买东西的。但是呢,实际上超市这个角色,本身没有生产能力!同学们,你重来没有见过哪家超市,会生产方便面,会生产洗衣粉,会生产各种小吃和玩具等。超市本质上没有生产能力,而超市等背后有一大批的供应商,或者说是一个个产品的制造商。
》那么实际上对我们来讲呢,我们超市的角色呢,它其实是供应商给我们供应过来的商品,放在超市里,预先放好之后么,让消费者随时来拿。那么我的问题就来了,那么消费者为什么不直接去供应商的工厂里面去拿呢?在早些年的时候,消费者想去工厂里面拿是绝对不可能的,你要的太少了。你跑过去跟工厂的老板说,来一包方便面,我想吃方便面,工厂老板说,你才要一包,我们这儿生产都是一万打底,作为消费者,你要的量太少,而工厂一次开动机器给你生产一包还够不上工费。所以在以前呢是根本不可能的,但话又说回来,放到现在呢,倒是有可能,你经常听到,在淘宝等说工厂直供等词眼出现,这个模式呢,是因为互联网诞生了,我们不考虑。在我们以前模式里面呢,消费者不可能直接去工厂里面拿东西,你也拿不到,因为你要的量太少了,这是其一。
》那么站在超市的供应商角度,它为什么不直接给消费者再去供应商品呢,而是先把东西给超市呢?原因也很简单,因为消费者太零散了,消费者散布在城市的各个角落,我们的供应商呢,把这批人找到,所需要的成本已经覆盖住卖商品所获得到的利润了。所以工厂也没办法直接将自己的商品发给消费者。但是超市有好处的是,大家可以看到,几乎所有的超市都是在人多的地方,那么超市呢就相当于一个集散地,我们工厂生产出来的东西呢,放在超市里供我们消费者来进行消费。所以工厂、消费者以及超市,在超市这个角色存在的意义呢,在于第一个:提高效率;什么叫做提高效率呢,站在工厂的角度呢,它一次可以生产一大批的我们所对应的商品放到超市当中,这就叫做提高了他的生产效率。消费者呢,体现在你需要的时候直接去超市去买,不用等供应商给你开动机器把商品澡出来给你。对我们来讲,这就叫做对消费和生产双方各自都提高了效率,而超市的角色就起到提高效率这样一个定位。
》我们再来一个,在你睡觉时,供应商有没有可能在生产呢?在你平时周末的时候,供应商的工作可能放假了,但是消费者可不可能去消费呢?随时随地都可能买到。也就是说,我们发现,如果直接消费者去我们工厂里去拿,消费者要,你这个工厂就必须得生产,而工厂生产了,你消费者就必须得要。要不然这个工厂就出现积压了。但是呢,其中有超市的存在,这个生产方呢,可以没日没夜的都在生产,假设我们经济非常好,超市卖的东西,只要有东西它就能卖出去,那么对大家都好。所以对我们来讲呢,工厂生产出来的东西,那么消费者就能够把它消费走。但是,消费者可能暂时不消费,而我们的工厂呢依旧可以去生产。而工厂呢此时可能放假了,只要超市里还有货,那么消费就能去买到。
》因为超市的存在,而把曾经只有消费者和生产者模式解刨开来,而变成了供应商可以随时随地的去供应了,而消费者可以随时随地去消费。消费者,在消费期间,并不妨碍工厂去生产,工厂在生产期间也并不妨碍消费者去消费。并且在没有工厂的时候,消费者也可以去消费;在没有我们的消费者的时候,工厂也可以继续去生产。那么换句话说,因为超市的存在,消费者的消费和工厂的生产,两者之间不再是强耦合关系。而变成了叫做松耦合关系。我们超市的第二个好处就是,解耦。那么什么叫做解耦呢,以前是消费者必须到工厂里面,你老板赶紧给我生产一包方便面,老板说行,你等着吧。老板就叫人开动机器,给你制作,那么在工厂生产期间,你必须得等,这个时候耽误的就是你消费者的时间。同样的,工厂生产出来了,他得去找到消费者,找到消费者才能卖出去。所以,工厂可能暂时生产了商品,不敢再生产了,它就等把这批商品消费完了,让消费者全部拿走了,它才会生产。这就是消费者和工厂有着强耦合关系。但因为,有我们对应的一个超市的存在,那么就可以大大的缓冲我们两者之间的工作步调不一致的问题。也就是说,消费者你可以随时过来消费,生产者呢你也可以随过生产。这就叫做让双方在一定程度的解耦。同学们,有的人说这个超市特别像缓冲区,你应该者杨理解,它不是像缓冲区,它就是缓冲区!缓冲区对我们的意义不就是,提高效率和解耦吗。所以对我们来讲,我们刚刚回答的是,超市的存在。我们就称作生产者消费者模型。那么其中对我们来讲呢,把每一个消费者当成一个线程,即消费线程。而我们的工厂呢对应着生产,就是我们的生产线程。其中呢我们把这种就叫做我们的生产者和消费者模型。
》下面呢,我们来进入第二阶段,那么其中,生产者要去超市,其中我们知道,生产者呢对应的就是工厂,它是一个线程,消费者呢,它也是线程。一个呢,要从超市里拿走东西,一个呢要把东西给超市。所以你现在给我所谈的超市的本质,在计算机里面,它就是一段缓冲区。超市一般就是内存中的一段空间,这段空间呢,可以有自己的组织方式。这个组织方式呢,我们一会儿再说,比如说,这个组织方式呢可以是一张链表,或者是队列。
》那么,下面我来跟大家谈谈,一个我们所对应的线程呢,或者是消费者,他要去超市买东西,生产者呢,他要去超市放东西。同学们,换句话说,是不是生产者或消费者他们都要去我们所对应的超市里面进行对超市内部资源的访问。消费者呢,要的是超市里面的商品。而工厂呢,要的是超市里面的一个个摆架,所以,不管怎么样,生产者和消费者都要同时访问超市,虽然两者是解耦关系,但并不排除两者之间共同访问超市的情况,因为我们很显然一个要生产一个要消费。一个往超市里面放东西,一个往超市里面拿东西。同学们,所以在这种模式下,我们的超市一种什么东西呢??一个被多个线程同时可能访问的资源,这个超市在生活当中的角色,我们就可以称之为,叫做
临界资源

》同学们,下面我们要谈生产者和消费者模型,既然话都说到这里了,有一个临界资源的概念,那么下面,我们要和同学们讨论的一个话题是什么呢,我们要讨论的一个话题就是。同学们,关于生产者和消费者模型,我们首先要讨论的就是,既然我们超市是一个临界资源,那么它就一定会被保护起来,要被保护,我们得想清楚怎么去保护这个超市。
》同学们好好想想,我们下面先来讨论几个问题,消费者可能有多个,在现实生活中,消费者可不可能有多个,可能有多个,如果消费者有多个,那么消费之间又是什么关系呢?这是其一。其二,如果供应商有多个,那么供应商之间是什么关系呢?其三,生产者和消费者之间又是什么关系呢?我们要让我们的生产和消费模型,后续能正常的跑起来,我们必须得把这几个问题讨论清楚。因为只有把这些问题之间的关系搞清楚了,我们才好去决定一个最终的目标就是,如何进行我们临界资源的保护问题---->进而提高访问效率。好了同学们,下面我们一个个谈论。
》同学们,告诉我,在生活中,消费者和消费者之间是什么关系?之前呢,有人说,消费者和消费者之间是没有关系的。有关系吗?关系大大的有,什么关系呢?叫做
竞争关系
。当我们同学听到这个的时候,很容易吃惊,消费者和消费是竞争关系,我去超市里面买一包方便面,从来没有和其他消费者打过架,起过冲突,我们没有争论过什么东西呀,同学们,那是因为东西太多了,超市里面,光方便面就占了很多位置,一个人拿了一包,你就去拿另一包,因为室友重复的东西。但是,如果超市里面还有一包方便面呢,你和你的室友打了三天游戏,你和你的室友都饿的快不行了,你们两个拖着疲惫身体到了超市,看到方便面,你和你的室友是什么关系,是竞争关系。我们现实生活中,消费者和消费者是典型的竞争关系。因为我们两个都要把商品拿走,那么如果商品推到极端情况喜下,我只有1个商品的时候,那么我们就是竞争关系。所谓的竞争关系是什么呢,不就是有你没我,有我没你吗。这是什么呀,用我们计算机语言描述就是。互斥关系。其二,任何事情都有特殊情况,比如说消费者,我们保证让这 些消费者消费不同的产品,那么这个互斥的关系是不是暂时也就不存在了。是的,这是特殊情况,我们先把普通情况看清楚。
》那么,同学们,供应商和供应商是什么关系呢?有人说,这个好想,供应商和供应商之间一定是竞争关系,对吧。为什么呢,就好比消费者,消费者和消费者之间争的是超市的商品,而供应商呢,他争的是我们超市里面的展架,你应该能理解,为什么呢,我们的供应商呢,供应了什么叫做白象方便面,你就不能给超市供康师傅,但是有人说,他是一个供方便面,我是一个供石榴的,你说我们两之间有冲突吗?记住了,谁不想多卖点,所以供应商和供应商之间其实理论上也是一种,互斥关系。同学们,这都是一些普世的情况,特殊情况我们后面说说。
》再下面一个问题就是,生产者和消费者之间是什么关系呢?同学们想一想,消费者和供应商之间,是一种什么样的关系呢,首先,消费者要去超市里面消费,而供应商呢,他要去我们的超市生产,供应商他最后是要把商品摆到对应的货架,摆上货架的时候,我问同学们一个问题,当供应商要把自己的商品放到货架的时候,他正在放的时候,这时候,消费者来拿了,请问消费者有没有最终把我们的生产数据或者商品拿走呢?也就是供应商呢,拿一包方便面正准备把它放到摆架上,此时我们消费者就拿了,他能拿吗?同学们,什么叫做正准备放呢?你到底是放了还是没放,如果你放了,我再拿就是拿到了,如果你没放,我去拿那就是没拿到。那么在生活当中 呢,我们不会出问题,但是在计算机里面呢,它写数据的时候,因为数据正在写,那么对方读取线程就来读了,它就可能把数据拿走一部分,比如说,我写一个1、2、3、4、5、6.,等,我想把10个字符串当作一包方便面,放到超市里,然后呢,我刚放了1、2、3,剩下的还没放,消费者就有可能拿了1、2、3就跑了,同学们,所以对我们来讲,什么叫做供应商正在放,正在放的时候,到底是放了还是没放,答案是:不确定。供应商正在放,不确定,也就决定了消费者能否消费到数据也是不确定的,甚至可能会读到错误的数据。那么这就叫做引起了数据的二义性的问题,因为生产者生产数据的时候,不一定是原子的,而消费者呢,它此时的消费也不是原子的,所以生产者和消费之间起码也要保证一种,互斥关系!也就是说呢,生产者在生产时,我生产出去了,生产在生产出去之间,消费者你来消费,不好意思你必须得等一等,你此时不能来消费,因为你得等我把生产完整,这就叫做我们通过互斥来保证,在消费者看来,生产者要么不生产,要生产就生产完,那么此时没有中间状态,这样的话,消费者在读取数据的时候,它才是叫做具有确定性结果。同学们,第一必须保证互斥关系。
》那么这就完了吗?还没有,我是一个消费者,我今天想去买一个iphone14,假设超市有iphone14,它在电子类展区,然后呢我想去买它,当我跑到店里问工作人员,说,请问现在有没有iphone14?工作人员告诉你,没有,你得等,因为商家呢还没有把iphone14摆上,然后呢,你就回去了。回去之后呢,又去问,请问现在有没有iphone14呀,摆货的工作人员又告诉你,对不起现在没有,商家还是没有给我们发货。那么你就这样周而复始的跑了很长时间,每次过去你都要问工作人员到底有没有iphone14.。同学们,而且你在访问这个工作人员的时候,工作人员是互斥式的给你提供服务,给你提供服务的时候,就不能给产商提供服务,它确实不错,没有出错,但是你天天跑过去,浪费的是你的时间,我们可以保证生产和消费之间是互斥,但是,我们互斥的情况下,天天让你检查超市里面某一个手机是否就绪了,那么对你来讲就是轮询检查,你天天跑过去问人家工作人员,有没有iphone14,把人家都问烦了,问烦了之后人家就跟你说,你还是别来了,这样我把你的微信一加,你就在家里等吧,我现在就开始联系厂家说有人要买,然后你就在家里等,当厂家把iphone14摆上对应的货架,有货了我就立马给你打电话。然后你说,行。此时你就跑到学校里面等着,等的时候呢,工作人员也没闲着,这时候供应商来了,来了之后呢,那么他就问这个工作人员,有没有人要买,供应商说,有呀,赶紧把东西摆上来吧。所以供应商就把iphone14摆在货架上。期间,在你问这个店员的时候,那么这个供应商也可能在问这个店员,有没有货架呀,有没有空余的货架来摆我们的产品呀。假设这个手机没有买的时候,这个工作人员可能对供应商说的是,没有,没有,你还是别问我了,大不了我把你微信一加,当专卖店里有所谓的展架的时候我再给你打电话。
》所以这个时候呢,我们就发现一个问题,如果只有互斥的情况,那么生产者呢,他一直都要来检测超市里面有没有空余的位置来摆放自己的iphone14,而消费者要不断互斥式的重复我们这个内部超市人员,有没有手机iphone14,同学们他们都关心着各自关心的资源,然后呢,通过互斥的方式,不断的轮询检测,它正确,没有错,但是它不合理,因为它的效率太低了。它耽误的是消费者和生产者,让他们两个不断的要问,太麻烦了,所以工作人员呢,加了消费者的微信,当生产者生产出来了并摆放到货架上,然后工作人员立马给你发消息,同学你好,你要的iphone14已经有了,你赶紧来买吧。所以这个时候你再跑过去买,其中呢,当我们的很多同学都去买iphone14,iphone14里面已经有很少的手机量了,这个时候,店员立马给厂商打电话,厂商呀,赶紧来把你的iphone14摆上来,现在该商品已经卖完了,你得赶紧来把商品摆上去。所以供应商呢,立马就摆上一大堆的商品放上去。那么,可是那段时间没有人消费了,没有人消费怎么办呢,店员就说,供应商你回去等着吧,等我们有正儿八经的人来买的时候,我的展架上空的时候,有大量的空位置,再来叫你把货品给我放上。我们明显感受到了,生产和消费应该要有一定的顺序,这种顺序叫做消费完了,再生产;生产完了,在消费。所以,同学们,生产和消费之间,除了要保证临界资源的安全性外,它还要保证生产消费过程之中的叫做合理性,所以生产者和消费者之间也应该要具备一种叫做
同步关系
。这个生活当中的例子,我们一想就能明白,所以对我们来讲呢,供应商生产出来对应的数据,我们工厂生产数据,生产出来之后,我们消费者才会过来消费。你的超市里摆满手机,那你就让消费者来消费呗,你就不要让生产者再生产了,因为锁资源,双方都要争。你超市里面已经摆满手机了,你还让 生产商过来问,有没有展架。然后你说,没有。然后这个厂家就回去了,回去之后,又来问,你这里有没有展架位?你说,没有。经过这样不断让生厂商不断轮询检测的时候,此时这里就出现了很多很多的问题,导致供应商的效率低下。所以一定要保证在访问的时候既要安全又要有顺序。同学们,想一下 ,生产者和消费者有没有可能同时来呢,有可能,有可能同时来怎么办呢,那就互斥呗。如果没有同时来,那我们两就同步呗。那么同步和互斥是矛盾的吗?让他们两个按照一定的顺序去执行,这两个的关系是矛盾的吗?答案是:并不矛盾。因为我们后面可以采用互斥锁加条件变量的方式,完成这种互斥且同步的关系 ,后面再维护。这是第二层意思,我们下面来第三次意思。
》再重新回过头把上面全部推倒, 再重来。同学们,对我们来讲呢,生产者和消费者模型呢, 那么我们现在知道,生产和消费就是典型的线程,而我们
的生产者和消费者呢,通常是由线程来承担的 。也就是说,线程就是我们口中的生产者和消费者。线程呢,它可以往里面放数据,也可以从里面拿数据,这叫做什么呢,这叫做2角色。我们超市对应
的一个角色呢,说白了就是内存中特定的一种内存结构或者说是一种数据结构。那么其中呢,这个超市更多的作用,叫做提供生产和消费的交易场所。那么这个交易场所呢,我们叫做1个交易场所。那么其中呢,对我们来讲呢,而生产和消费模型当中,我们有
生产者和生产者(互斥关系),消费者和消费者(互斥关系),还有生产者消费者(互斥➕同步关系)
,其中这里呢就对应着3种关系。那么其中,我们为了让同学们,迅速的记住生产和消费者模型,我们将它称作生产和消费模型的 3 2 1 原则。这个原则不是别人的,不是操作系统梳理的,是我发明的。同学们不要上来直接给别人说 3 2 1原则,而是用它来理顺思路。也就是说当别人问你,什么叫做生产者和消费者模型呢,那么你脑子里立马想到的就是3 2 1原则:3种关系,它首先是采用一种通过我们经过交易场所方式来让生产和消费,一解耦,二提高效率,这就是它对应的为什么要有它;第二个呢,就是生产和消费者模型呢,它呢,通过数据层面上,通过缓冲的方式,让生产和消费实现解耦,那么可以提高我们对应的效率,像这种情况我们一般就叫做生产和消费者模型,而他要遵守的一些规则呢,比如说,它要维护3种关系,生产者和生产者、消费者和消费者中、生产者和消费者之间关系,然后呢,第二个,他们一般有两种角色,生产者和消费者通常由线程来承担,另外,生产和消费,也要提供一个交易场所来交换双方的数据,所以呢,我们可以通过 3 2 1原则 快速的记住生产者和消费者模型。
》也就是说呢,未来在进行对应的编码,写代码的时候,就是要完成 3 2 1原则。也就是说,我们未来要写各种各样的代码,不就是用我们的代码来实现你说的3个原则吗,首先就是缓冲区谁提供,几个线程,线程和线程之间,互斥关系,该互斥互斥,该同步的关系,维护同步。你按照这样的知道思想,然后再设计思路,然后写代码,那么它的条理会更清楚。同学们,这就是生产和消费者模型 。那么,其中在我刚刚讲的生产和消费者模型当中,有一个重要的概念,叫做,我们工作人员呢给消费者说,你现在回去吧,现在还没有iphone14,你去等一等,等超市里面有对应的iphone14的时候,我再叫你。那么,**如何让我们的多个消费者线程等待呢?又如何让线程被唤醒呢?**你怎么知道让这个线程等呢,等完了,特定条件满足了,你怎么把它唤醒呢。 这是其一。其二,如何让我们对应的生产者线程进行等待 呢? 那么,当超市条件满足的时候, **如何让我们对应的生产者线程唤醒呢?**很显然,我们在这个场景当中,一定要提供一种让其他线程等和能够唤醒其他线程的一种工作。 其中,我们学的什么东西可以让一个线程去等,当条件满足时,我们可以唤醒这个线程呢?谁能做到?当我们超市内部的条件满足或不满足时,我们要做出对应的等待或唤醒的动作,谁来做这个工作?同学们,这是第一个问题。第二个问题,如何衡量消费者和生产者所关心的条件是否就绪呢?比如说,我怎么知道超市里面有数据已经就绪了,我又怎么知道超市里面有手机了,怎么知道超市里面有展架了。这就叫做,你怎么衡量消费者和生产者关系的条件是否就绪呢?好,这些问题呢,都是要通过代码来体现的,但经过我们之前的学习,大家隐隐约约能感受到,其中前两个问题,如何让生产或者消费者去等, 如何唤醒生产和消费者 ,那么很显然,我们已经有了一个货,这个货呢就叫做,条件变量!另外呢,我们还看到它们里面呢还有互斥关系的,那么我们一定要用到互斥锁,而恰好条件变量的使用,它就是要配合互斥锁来进行使用的。
》从我们现在谈到的内容当中,发现一个问题,叫做,条件变量实际上呢,它有让线程去等的功能,也具有唤醒线程的能力,这里我们是能感受到。第二个呢,生产和消费者模型里面,既有互斥的关系,也有同步的关系要维护,而恰好,条件变量里,就带了互斥锁,需要条件变量和互斥锁共同使用,难道就这么巧合吗?就这么巧合,它天然的设计就是为了让我们现在这个场景设计出来的。来,同学们,至于第三个怎么衡量生产和消费者他们的条件是否就绪,这个呢,我们需要在代码当中体现出来。好了,同学们,如上就是我们对应的第二个阶段,叫做生产者和消费者对应的一个模型的概念。
》我们再来给大家把场景扩大一下,我们见到生产和消费者模型,那么,我们也同样要见一见,也得重新复盘一下一些不是生产和消费模型的情况。那么,我来给大家举一个同学的例子,假设,这里有一个int a = 100;然后呢,int b = 200;然后,int c = add(a,b);cout << c << endl;我们再写一下add函数:int add(int a , int b){ int c = a+ b; return c;}那么你刚刚给我讲的叫做什么呢, 你刚刚给我讲的叫做生产和消费者模型,它是一种已经被解耦的情况,那么我呢,除了以后未来要写的这种解耦的情况之外,我还得见见,没有解耦的时候是什么意思。我们得复盘一下,我们曾经写的代码是在做什么。
》比如说呢,我刚刚写了一个大家最常见的情况,就叫做a 和 b,它呢,经过函数调用,调用之后在add函数里面做处理,处理完之后,再把结果返回,这就是我们之前的一个非常普通的函数调用,那么我们 今天呢就换一个视角重新看一下它。同学们,假设这个执行的代码呢,它是一个执行流,当然我们知道,这是单进程执行流,其中,这个add()函数呢最终被调用时,实际上是我们主执行流,把对应的a和b这样的数据,交给了add()函数,叫做生产数据,我把a、b交给了add函数,生产出来两个数据,我要的是什么呢,我要的是你这个函数把我对应生产出来的数据做处理,处理完之后,再把结果给我返回。那么同学们,其中对我们来讲,其中main()函数里面对应的调用了add()函数来执行我们数据的加工处理。而数据加工处理完成之后再返回来给我,那么其中同学们,假设a和b就是我们对应的数据,那么我们在调用这个add期间,调用add的时间非常久,那么此时你这个main执行流,你必须得等这个add执行流执行完有返回了你才能继续往下走。当然我们都知道是单执行流嘛,换句话说,我执行到add函数,我必须add函数有返回了,我才能继续执行下面的代码。同学们,这是什么呢,这就是一个单执行流,那么这就叫做,我的main执行流和add函数强耦合关系,我们今天暂时不考虑返回值问题,我今天就单纯的想把数据,假设我不返回,我把计算的结果,cout << a+ b << endl;了,我就是要把a、b生产的数据呢喂给add,add就调用传参,然后形成我们对应的变量做计算,做完计算之后,此时再把结果返回,那么在add他这里执行的时候,此时我main()执行流,不能继续往后走,为什么呢,因为我必须得等add函数调用完我才能走。这就叫做强耦合关系。后来呢,我干了一件事情,我做了一件什么事情呢,我把这个main()函数,对应的他要做的一个工作,和我们对应的add代码呢,分别交给了两个线程。交给了两个线程呢,并提供了一段缓冲区,我不再调用你这个add函数了,我改而调用函数put,也就是说呢,我的这个main()函数呢,它当前调用的工作,就不再是以前的阻塞式的去调用你这个add接口,必须得把你这个add调用完了,我才能继续往下执行。现在main()函数是一个线程,add也是一个线程,那么我的做法就是只要我把a、b两个数据生产到缓冲区里,然后呢,生产到缓冲区里,你这个线程什么时候拿,你怎么拿我都不管,反正我只要把a 和 b放到缓冲区里,此时我main()函数就直接返回继续执行下面的代码,那么其中我是while循环,向缓冲区里面打了大量的a和b,然后怎么办呢,你另一个线程add,那么你此时在add的时候,你做的工作就是get()获得我们对应的数据,不用通过我们以前的传参的方式来阻塞式的拿到数据,你Get的时候,你把a和b数据拿到,拿到之后呢,你再进行数据处理,你当然也可以while循环不断加锁。同学们,这个呢就叫做,我们把两份代码实现了解耦。而以前呢我们是串型的执行,我把他们拆开之后,就变成了解耦的关系。那么此时我们就有可能在我这个put线程往缓冲区放数据的时候,add呢可能同时正在处理上一次的数据,那么我放数据的时候,只要缓冲区里面还有空间,那我就可以一直放,放完我就返回。而不像以前,等你这个add线程把数据执行完,把结果给我阻塞式调用完,完成之后再给我,我这个函数不用再等你了,而以前这种函数调用的方式,我们必须把add调用完了才能够继续向后执行,现在变成了,我把数据交给缓冲区,然后我就不等你执行完了,我把数据拷贝到缓冲区里,从内存到内存效率一定是最高的,而你add的计算可能非常耗时间,但是不重要了,我呢,main()继续执行我的代码。这就是解耦的情况。
》但至于你说的这个情况,我该怎么去实现呢,那么我们到下面再给同学们展开来说。生产者和消费者模型,原理全部讲完。那么当然,脑子里有很多的问好,因为最终必须得体现在代码里面,只要把代码写出来了,一切的原理就一定会豁然开朗。

我们使用条件变量进行等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足(所以我们一般使用我们的条件变量的时候,一般是多线程的,那就有一个线程呢,通过某些操作呢,他要去改变我们共享的变量),所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
》条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。(其中有一个概念就是,条件和条件变量。我们给大家举个例子来理解,其中有一个说法是,条件和条件变量,首先,这个条件指的是,在你对应的某些共享资源状态,就比如说我们以前写的抢票,抢票的那个逻辑呢,对应的共享资源的状态,就是那个票数有没有 < 0 或者 有没有 = 0,这叫什么,这叫做你一定要通过判断的方式,来判断对应的资源是否符合你的条件,如果符合的话,符合你抢票的条件的话,此时你才能抢票,所以这里的条件呢,它是在我们对应的程序员视角去看待对应的共享资源的状态信息,是否满足我们可操作的要求。而条件变量呢,它是在条件满足或者不满足的时候,进行wait()或者signal()一种方式,或者它指的是与你的应用场景强相关的一种我们共享资源的状态,就比如说我们刚刚举的抢票,以及我们接下来要讲的阻塞队列,其实是一个道理。那么**条件呢对应的共享资源的状态是什么意思呢?我们程序员要判断资源是否满足自己操作的要求。**比如说你自己要判断,当前你的抢票是否符合票数 >0 如果 >0 你才能抢,不符合的话,此时你就必须得等,这就叫做你自己代码当中的条件;再比如说,我们之前写的进程间通信,写管道的时候说过,当我们把管道写满的时候,那么生产数据的一方不能再继续写了,那么管道被写满这种情况,就叫做条件满足或者不满足,在写方就称作条件不满足,它不能再写了,它必须得等了。读者的一方呢,它必须得把数据拿走,拿走了的话呢,其中对应的我们管道没数据了,那么拿数据的一方就必须得等待,这叫做什么,这叫做拿数据的条件不满足。所以这个条件呢,指的就是我们,实际上在做一件事情的时候,多线程做一些事情的时候,我们可能共同访问的一些共享资源,这部分共享资源的当前状态是什么,我们就是条件的意思哈。那么条件变量呢,就是通过其中判断这个条件是否满足我们的要求,来决定是否让当前线程去等待资源的概念,这个条件是由我们程序员通过代码去判断的。这个条件变量呢,是给我们提供一个机制,一种我们对应的同步机制,让我们可以使用条件变量,让多个线程步调统一起来。此时我们就可以理解“我们使用条件变量进行等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足”,比如说,管道里面,你往管道里面去写,当你把管道里面的内容写满了,你现在还想写,对不起你不能写了,这叫做条件不满足了,你现在程序员判断,管道满足不满足,不能写了怎么办,那么就让这个线程直接挂起等待了。让它在某一个条件变量下去等,可是呢,当他在某一个同步机制下去等的时候, 那么你只有一个线程的时候,没有人去更改你的数据,没有人更改你的全局数据,那么全局数据的状态就不会发生变化,就好比我们的管道里就没有人去读数据,没有人读数据那你怎么办呢,那你此时是不是把你写的这个进程或线程挂起是没有任何意义,就这个意思,所以我们条件变量应用场景呢,是多线程的,有人进行A操作,那么就一定有人B操作。就好比有人写数据,就有人拿数据。所以对我们来讲呢,因为状态在变化,所以条件不满足时才有可能,在变化过程当中再满足,从而让我的线程再被唤醒,其中这种没我们后续要完成这种线程间同步的,就要用到的就是条件变量。所以对我们来讲,有时候自己想访问共享资源的时候呢,先进行加锁,加锁之后,在共享资源内部呢,做资源的判断,如果当前我们某种条件是不满足的,不满足的时候,此时就应该让其在内部进行等待,在当我们条件满足的时候,在让另一个线程来通知我。所以对我们来讲,在一般设计我们的条件变量的时候,是这样写的,
// 错误的设计 pthread_mutex_lock(&mutex); while (condition_is_false) { pthread_mutex_unlock(&mutex); //解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过 pthread_cond_wait(&cond); pthread_mutex_lock(&mutex); }pthread_mutex_unlock(&mutex);
粗粒度看一下,一会儿我们边写边讲的,这里有一个错误设计,我们对我们的临界资源进行加锁,最后解锁。在中间我们判断条件是否满足,满足的话,我们此时对其解锁,然后进行等待,然后再进行加锁,这个呢,是一个错误的设计,细节我们后面再说。反正大家意思能明白,就相当于我们一般在进行我们的条件变量的使用的时候,你是先要进入临界资源里面的,然后呢,对我们当前的资源做条件判断。)
》对我们来讲呢,课件里面####条件变量使用规范等,是补充内容,是对于条件检查这样的概念,因为同学们现在没有场景,所以什么叫条件不满足呢,没有场景的话,其中我们上面说的东西,同学们其实也不太能理解,我的要求很加单,只希望大家能记住,条件 和 条件变量之间的概念。

✳️为何要使用生产和消费:
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。

生产消费模型的优点:
解耦
支持并发
支持忙闲不均(也就是生产线程很忙,消费者线程也很忙,也有可能两者之间有一个不忙,没关系,你们忙的时候把生产数据放到我们的仓库里,然后另一方呢从仓库当中把数据取走。上面说了再多,我们得有一份代码,把代码写完了,我们在回过头看上面的概念的时候,我们才能真正理解。)

✳️BlockingQueue阻塞队列
接下来呢,我们就来认识一下什么叫做阻塞队列,阻塞队列呢就是我们要写的一份代码,什么叫做阻塞队列呢,它的意思是,在我们多线程编程中,阻塞队列是一
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞(比如说我的队列里没数据了,你作为消费者你想要取数据,不好意思,你不能取,所以对我们来讲呢,与普通的队列没有区别在于),直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)
》当给大家念这个概念的时候,大家是不是就能想到一个东西,这个东西就叫做管道,同学们,管道呢,我们曾经在使用管道的时候,我们发现 ,如果我们把管道的内容写满的时候,那就不能往管道里面写了,如果我把管道的内容读空的时候,那就不能往管道里读了。如果此时对方按照一定的顺序给我们 abcd,我读到的也一定是abcd这样的顺序。同学们这叫做什么,这就叫做,我们对应的阻塞队列,所以管道呢底层虽然不是用我们STL实现的阻塞队列,采用的是一种环形队列的方式来实现的阻塞队列,但是它的原理其实是类似的。
》好同学们,那么这就叫做阻塞队列,一个线程往阻塞队列里扔数据,一个线程往阻塞队列里拿数据,那么这就叫做阻塞队列。当队列满的时候,就不能放了,队列当中为空的时候,就不能拿了,为满为空就是,我们程序员所认为的条件不满足,也就是我们队列的状态。程序员会判断这个队列是否为满了, 是否为空了,如果为满了,那就不能让我们的生产线程生产,如果为空了,就不能让我们的消费线程来消费。条件不满足时,都应该把你们两阻塞,各自进行阻塞。当我们条件满足时,比如说,我作为一个消费者,我检测到,条件变量为空了,检测到该条件为空,此时我就应该去某个条件变量下去等,等待着被别人唤醒,这就是条件和条件变量二者之间的关系。一个是程序员维护的,一个是条件变量的能力,这个我们要弄清楚。
》我们现在就要模拟一个阻塞队列,然后让我们的多线程去使用,所以,同学们,为了方便我们能够阻塞队列的设计呢,我们写的第一批代码呢,一单生产者,单消费者来进行讲解。单生产者我们知道,只需要维护同步和我们所对应的叫做互斥关系就可以了。当然同学们,如果我们有多生产者和多消费者,我们也不怕,也是有方法的。 这个我们后面再说。
》下面呢,我来给大家说一下,首先我们要设计的阻塞队列,我们把它的大小给他固定了,然后另外呢,因为是阻塞队列,而且呢,我们就像让他以队列的方式去工作,所以我们呢,也就不再给大家从0开始写你们一开始写数据结构的那些东西了,我们呢,直接使用STL当中queue来进行阻塞队列的设计。其中呢,我们还要有东西判断为空和为满,还有保护阻塞队列的一把锁哈。同学们,关于这块呢,我一会儿给大家慢慢写。下面呢给大家说一下,我们阻塞队列呢,课件里面写的,它就是一个生产数据和消费数据的过程,我今天呢,也想给大家再变一下,我想把阻塞队列设计成模版类型,模版类型呢,后续呢,我想给大家再加一点场景,让同学们能意识到,模版类型呢,其实是可以让我们去做很多事情的,以及阻塞队列的意义是什么呢,我们都会给大家去讲。
》我们首先来一个BlockQueue.hpp,BlockQueue.cc,Makefile。我们先来编写我们的Makefile,因为我们要使用的是一个多线程,光光g++ -o $@ $^ -std=c++11是不够的,还得指明我们用的第三方线程库,即g++ -o $@ $^ -pthread -std=c++11。今天呢,我们还可以再加点东西,其中呢,Makefile内部也是可以定义变量的,我们加一个:FLAGS=-std=c++11,里面尽量不要带空格哈,然后LD=-lpthread(表示要链接的库)。那么其中呢,我们就不用g++ -o $@ − p t h r e a d − s t d = c + + 11 这样了,不用再写原生符号了,直接 " ^ -pthread -std=c++11这样了,不用再写原生符号了,直接" pthreadstd=c++11这样了,不用再写原生符号了,直接"()“,记住在Makefile里面,定义变量是 “=”两边不用带空格,直接就是左边变量的定义,右边是变量的内容。然后我们用”$()"来取某一个变量的内容,即g++ -o $@ $^ $(LD) ( F L A G S ) ,其中呢我们就把刚刚的两个选项用上了,甚至我们可以再加,我们此时, C C = = g + + ,然后此时又可以变成 (FLAGS),其中呢我们就把刚刚的两个选项用上了,甚至我们可以再加,我们此时,CC==g++,然后此时又可以变成 (FLAGS),其中呢我们就把刚刚的两个选项用上了,甚至我们可以再加,我们此时,CC==g++,然后此时又可以变成(CC) -o $@ $^ $(LD) ( F L A G S ) ,然后我们要生成的目标文件, b i n = B l o c k Q u e u e , s r c = B l o c k Q u e u e . c c ,然后 B l c o k Q u e u e : B l o c k Q U e u e . c c ,就变成了, (FLAGS),然后我们要生成的目标文件,bin=BlockQueue,src=BlockQueue.cc,然后BlcokQueue:BlockQUeue.cc,就变成了, (FLAGS),然后我们要生成的目标文件,bin=BlockQueuesrc=BlockQueue.cc,然后BlcokQueue:BlockQUeue.cc,就变成了,(bin)😒(src),rm -f BlcokQueue变成了rm -f $(bin)。至此呢,我们可以把所有这些Makefile内部使用的细节呢,我们全部把它换成我们对应的变量,后面呢,你以后想要改的时候,就把上面的内容改改,下面就不用动了!
》下面我们开始编写BlockQueue.hpp,我们这个阻塞队列要怎么设计呢,将来呢,我想要这样设计,对我们来讲呢,在我们对应的代码里面呢,写一个伪代码。我们先定义一个阻塞队列,然后创建两个线程,一个是productor生产者、一个是consumer消费者,那么其中对我们来讲,然后呢,我们把productor和consumer用对应的阻塞队列把他们两个连接起来,一个通过阻塞队列来进行生产,另一个去消费,也就是多线程我们写在这BlockQueue.cc,纯纯的写阻塞队列,写在BlockQueue.hpp里面。关于多线程使用我们稍后再说。
》我们现在BlockQueue.hpp里面写,我们要设计一个队列,这个队列呢要保证,保证什么呢,它这个队列内部要保证内部的元素为满的情况下,就不能让生产者生产了,如果为空的情况下,就不能让我们的,消费者来消费了。那么其实我们这个队列呢,称之为阻塞队列。因为我们上面就说,我们要实现模版嘛,所以tempalte,将BlockQueue类写出来,class BlockQueue{};对于阻塞队列而言,首先要想到的时候什么呢,首先要知道阻塞队列,它里面放什么东西,对不对。同学们,阻塞队列我们放什么呢,首先作为一个阻塞队列,你得有有队列吧,对不对。 所以声明一个队列成员变量,即queue bq; 其二,同学们,应该也知道,我们一般阻塞队列是有容量的大小的,为空的时候就为0嘛,满的时候是不是就要等于容量,就要有容量呀。容量大小的话,数量这个东西,你元素个数的东西,要么就是为0,要么就是为其他值,即声明为uint32_t cap;那么其中呢,我们今天是想做一个内部的,我们认为成员变量都带一个下划线,证明他是我们类内的成员变量,其他的,比如说你将来类外部定义其他的变量,你可以正常去写,但是类内部的成员变量都带一个下划线,证明我们一看就知道它属于我们类内定义的成员变量。再下来对我们来讲呢,这个队列将来可能会被其他人,一个线程来生产和以个线程来消费,所以就有可能被所有其他线程来访问,所以要访问的话,其中这里的队列将来就充当的是一种需要被保护的全局变量,此时我们就需要用锁来保护它。同学们,如果此时一个变量要用锁来保护,那么此时你必须得有pthread_mutex_t mutex;来一把互斥锁来保护我们的阻塞队列。大家想想一想,如果我们只有锁保护,我们代码怎么写呢,生产者他要进来,它要生产,他怎么生产呢,是这么写的哈,它首先呢要进行,作为一个阻塞队列,你能理解的一个点就是,作为阻塞队列,它首先呢一定要提供一个能放数据和一个能拿数据的一个接口,对不对。比如说我们得有一个push()接口,这个方法是不是将来对应的生产函数。也就是说,一个生产要通过调用push来把任务呢,push到阻塞队列里对不对。同样的,我们还得来一个,消费接口,void Pop(),通过这个接口来拿数据。也就是说,我们这个阻塞队里呢,你一定要提供两个接口,至少两个,一个就是void Push()往我们队列里push内容,另一个就是void Pop()从我们的阻塞队列中呢,把数据拿走。这就是一个来生产,一个来消费。假设以生产者为例,你要生产一定要进行加锁,因为消费者他什么时候消费时他也要加锁。用完了,当然要进行解锁。既然你们两个既要加锁,也要解锁,那么在加锁和解锁之间,你要进行生产或者消费。这个呢,对吗?答案是:不对!因为,你想生产就生产吗 ,所谓的今天的生产不就是把你对应数据,放入到我们的阻塞队列里面吗?所以你想生产就生产吗?所以你想生产前,首先得做判断! 你要判断什么呢,你要判断当前是否适合生产,什么意思是呢,就是阻塞队列是否为满。如果满了怎么办,如果不满怎么办,这是我们程序员视角的条件。那么就会存在两种情况,一、满了怎么样;二、不满怎么样。如果满了,那是不是应该就不让你生产,而是让你等待。如果不满的话,你就继续生产。想想如果没有后续的程序员视角满了或者不满怎么样,那么你生产要加锁,然后判断是否符合条件,如果不满足直接解锁不生产,这种存互斥的方式就会带来结果,生产者加锁,然后判断,条件不满足,那就不让你生产,解锁,解锁之后就退出了,我退出了就是我接口调用完成,但是我这个线程是不是下次又跑过来,先加锁,然后判断又问条件是否满足,不满足则不让你生产,然后解锁,退出函数调用。同学们,你今天就在这里重复重复,可是你再重复期间本质是不断申请锁和释放锁,可是因为你的优先级比较高,而导致你不断的申请加锁和释放锁,导致人家消费者来不及申请锁,抢不过你呀!所以你一直都在干什么呢,加锁判断,条件满不满足 ,又解锁,同学们这叫做什么,这是不是纯互斥,那么你的生产者一直在抢占锁,导致你消费线程想消费时,造成了消费的“饥饿问题”!同学们,这里一定要说清楚,不然后面条件变量用起来就乱了。
》再说一下,就相当于我判断,此时我就有了这么一个结果,if(bq满了)那么就不生产,else if(bq不满)才进行我们的生产。我们生产线程,它先去加锁,加锁之后判断,如果满的话,就不生产,直接解锁,退出函数调用,然后回过头继续进入Push函数里继续加锁…等一系列操作。如果此时我的队列已经满了,而消费者就是不拿数据,那么我们的生产者线程,是不是大部分都在申请锁,判断,满了就不生产,然后解锁退出函数调用,再循环再调用Push函数,接着一系列重复操作,所以,同学们,此时如果只有单纯的互斥锁的情况下,我们就发现,在生产这里呢,我们就可能存在生产线程频繁的去申请锁。频繁申请锁,但是它进入我们的临界资源之后,访问临界资源的时候执行的代码,对不起它在里面什么都没做,然后就是申请锁,加锁、解锁…条件一直不满足,此时这个生产者就出现了一个叫做,轮询检测条件的方式,在此期间,我们恶意的去竞争锁而导致另一方饥饿的问题,同样的对于消费也是如此。消费者也要加锁,然后判断,当前是否有我们对应的数据呢,如果有的话我就消费,没有的话,我就解锁,退出函数,然后继续重复此操作。这会带来什么问题呢,这会带来双方互相去竞争我们对应的锁,导致我们执行的时序比较乱的,这是第一。第二,刚刚我们描述的场景有错吗?我们用一把互斥锁,描绘的场景有错吗?答案是:没错! 你消费者现在争不过人家,能怪我生产线程吗,我现在先申请锁,判断检查的时候,对应的阻塞队列呢, 当前它里面的内容呀,也是属于临界资源的内容,我要对它做检查,检查的时候也要访问大小,所以检查的时候,你满的话,我就不能生产了,那我就解锁退出就行了,下次我再来问,反正你又没有让我做其他事情,我就继续循环执行你Push()接口,它此时叫做,没错,因为临界资源不会出问题,但是它不合理,我们正常情况下,你不仅仅要不生产,你还要进行休眠。说白了就是,你如果判断你的条件不满足,你不仅仅你不要生产,你赶紧去睡会儿吧,等一会儿的时候呢,当我们的条件满足的时候,我再叫醒你,此时你就不会去频繁的加锁解锁,加锁解锁…当你休眠时,就没有人和消费线程去争了,没有人和他去竞争了,同学们,没有人和消费线程去争了,那意味着什么呢?那意味着消费线程就可以加锁,然后呢,自己就可以消费了,只要它把空间消费走,那是不是就立马可以把生产线程叫醒,让他立即来生产。所以换句话说呢,我们需要有一个条件变量的方式,来进行让双方能够进行,在特定条件下,不满足条件则不生产且还是休眠的状态,同样的消费者呢,在进行我们的消费的时候,不满足条件,他也会休眠,然后让双方彼此的唤醒对方。这种呢,我们就称之为同步式的阻塞队列。前面的铺垫动作必须得给各位做做,要不然一会儿就蒙圈了。
》消费者也是判断,判断是否适合消费,如果阻塞队列bq为空的时候,---->程序员视角条件,空(不消费);不空(消费)。if(空)不消费,且休眠;eles if(不空)消费;其中呢,对我们生产者来讲呢,满了,它就不生产且休眠去了;假设没有满的话,可能队列里面放10个数据,不满的话可能1、2、3等情况,不满的话,我们就在这里进行生产,生产完了一个,就可以唤醒消费者。为什么呢?因为在我们不满的情况下有可能上次的时候队列里面就没有数据,没有数据的时候,此时对应的消费者呢,就不能进行消费了,所以他一定是出于休眠状态,所以我生产现场生产了一个我就唤醒消费线程,此时消费线程被唤醒,那么此时队列里面一定就有数据了!同样的,当我们消费线程消费完了一个,若为空我就休眠了,如果有数据我就消费了,只要我消费了,这里什么叫做有数据呢。有10个算有,有1个也算有!有的时候我消费完了一次,此时我们这里消费线程消费走了一个数据,就意味着当前阻塞队列里就腾出一个空位置,所以我们可以唤醒我们的生产者。我们文字描述的就是一个伪代码,或者说是流程说明。这段流程说明和我们一会儿写的代码呢基本是吻合的,但会有点点差别。下面我们不多说,一会儿要push和pop接口一会儿再填。
》我们今天要有,第一,让生产者在不满足生产条件的时候休眠,第二,消费者不满足消费的条件的时候,它也要进行休眠。所以我们需要有pthread_cond_t consumer_cond_条件变量成员,让消费者等待的条件变量,那么它呢,是一个让消费者等待的条件变量,当然让消费者等待的条件变量一定是将来生产者线程要唤醒消费者使用的条件变量。再下来呢,pthread_cond_t productor_cond_;让我们生产者等待的条件变量。所以我们对应的阻塞队列里,可以放入上的成员变量。
》我们先来写可以叫做生产push()接口,它生产什么呢?那一定是生产T类型的数据,当然在生产之前,我们得有构造函数,构造函数这里呢,构造的时候,你得告诉我阻塞队列的大小,一个阻塞队列你只要告诉我容量大小就可以了,我们可以来一个默认的容量大小const uint32_t g_default_cap = 5;设置了一个全局的默认容量大小为5。可以在初始化列表阶段初始化cap_,即cap_(cap)。我们将cap_成员变量声明放在前面,尽量和我们的初始化列表一致先能够初始化我们cap_。还有在初始化列表阶段对我们的bq_进行初始化,即bq_(cap)。然后再{}里面对我们的锁和条件变量进行初始化,即调用pthread_mutex_init(&mutex);pthread_cond_init(&concond_, nullptr);pthread_cond_init(&procond_, nullptr);初始化做完之后,就要写析构函数了。
》析构函数,bq_我们不用管,系统会自动调用他自己的析构函数,然后我们只需要对锁和条件变量进行销毁,即pthread_mutex_destroy(&mutex);pthread_cond_destroy(&concond);pthread_cond_destroy(&procond);
》我们要给外部提供的接口,无外乎,一个阻塞队列我给你提供的接口,现在就认为提供两个接口,第一个让你生产,第二个让你消费。来我们来写生产Push()接口 ,我们生产是不是就是要生产什么数据,你是阻塞队列,你条件不满足,生产什么呢?我们生产出来的数据是要放到队列当中的,所以此处我们在push的函数参数为const T& in,即Push(const T& in),我们要把数据in放入队列进行生产。消费接口void Pop(const T* out),我们用const &表示纯输入型参数,type* 表示输出参数。当然,你想用返回值pop获取数据也可以,即 T Pop();我们继续写push,构造函数也给你写好了,析构函数也给你写好了,接下来呢,我们要生产数据,是不是首先得把你的队列锁住。我今天给大家做一下封装,在给大家写下一个接口的时候就不做封装了。我们在Push里面写一个,LockQueue()函数,把队列锁住,锁住用完之后,你再UnLockQueue(),在加锁和解锁之间,我们首先得是做判断。你觉得你应该做什么判断呢?你要判断的话,是不是判断当前的队列是否为满呀。所以判断怎么做呢,我们可以if(isfull()),你当前不是要进行生产吗,我们是先把队列锁住,然后判断队列是否为满,如果满了,作为生产者,我此时应该在生产者条件变量下等待,我们就调用BlockWait(),其实就是阻塞等待,在条件变量下等待被唤醒。所以条件不满足,我就阻塞等待,等待被唤醒,我在等待,是不是代码就不会往后执行了。BlockWait()还有内容我们一会儿说,然后if(IsFull())还有bug。走到if()如果当前队列是满的话,那么你就只能等了,在你等待期间怎么办呢?在等待期间就只能处于某种挂起状态。若条件满足,则就可以生产。怎么生产呢,我们可以来一个bq_push(in)生产的过程不就是把in给你push进去嘛,这就完成了生产,因为我们用到了STL容器。生产完成之后就是解锁,后面还缺一步我们一会儿再说。
》再来谈谈消费,消费低吟件事情,我得LockQueue()毫无疑问,不管是生产还是消费我们都得是先将我们的阻塞队列锁住。然后再进行UnLockQueue()解锁,此时我就有了一个加锁和解锁。我是要来消费的,你是不是得先判断是否满足你当前的消费条件对不对,所以if(IsEmpty),队列是空的,那你是不是不能消费,那么你是不是也得在对应条件变量下阻塞等待,BlockWait(),注意此时就有了两种等待,我们得区分一下了,所以生产的阻塞等待就叫做ProBlockWait(),消费的阻塞等待就叫做ConBlockWait() 。当消费线程等待的时候,也要等待被唤醒。当条件满足的话,那就可以消费了,因为走到可以消费的时候,你一定是进行了先加锁,然后判断,要么条件满足,你就直接消费;当条件不满足,你就去等待了,你是阻塞等待的,需要被唤醒,唤醒之后就会执行下面可以消费的代码了。怎么消费呢,是不是定义一个临时变量T tmp = bq_.front();然后bq_.pop(),后面就是解锁,返回return tmp;我们做了封装,只是对锁进行了封装,其中生产数据和消费数据这块没有做封装,因为它里面很简单。我们的代码粗力度完成好了,我们现在来第三批。
》我们不是锁住队列LockQueue()和UnLockQueue()还没实现吗,所以就要来实现他们。
》比如你现在要锁队列LockQueue(),那怎么办呢,我们得调用pthread_mutex_lock(&mutex_)函数。然后我们还有UnLockQueue()函数中,调以哦那个ptrhead_mutex_unlock(&mutex)调用解锁函数,我们封装之后就不用在生产和消费这里分别还得花时间去再重复写一遍。下面我么们的动作是判断队列为空为满,即bool IsEmptrty()函数,然后调用队列的判空函数及bq_.empty()函数,我们想做好封装,为什么要做封装呢,因为你将来不想再用队列来做容器了,那么你可以把成员变量换掉,然后private的接口稍微缓一下,public部分不需要变动就行。然后还得再写bool IsFull()函数,调用队列的大小函数bq_size()函数。好了,我们现在锁住和解开队列写了,判空和判满也写了,现在就要来写条件变量等待了。我们在private里面要加消费者条件变量和生产者条件变量。我们先来写生产者条件变量等待,void ProBlockWait()函数,能调用此函数,生产者一定是在临界区的!想想是不是这个道理,我们在调用这个ProBlockWait()函数的时候,我们是不是就是放在加锁和解锁之间,那是不是临界区呀,这是第一个认识。第二个,让一个线程挂起阻塞并不困难,因为我们可以调用pthread_cond_wait()函数,第一个参数,是我们想让生产者去对应的条件变量等待,那是不是&procond_,此时这就叫做生产者在生产条件变量下等待,可是你别忘了,你在调用此函数之前,你是对队列加过锁的,你是LockQueue()的,所以你现在调用ProBlockWait()阻塞等待,而我们的生产和消费用的是一把锁,因为我们要维持消费和生产的互斥关系,所以第二个参数,我们得&mutex。我们暂停一下,我们曾今学过,pthread_cond_wait()函数,当我们调用此函数时,第一个参数是条件变量,第二个参数就是一把互斥锁。我们也说了,当你条件不满足的时候,在该条件变量下等。然后呢,你会发现,实际上,我怎么知道条件满足或者不满足呢?你怎么知道条件满足不满足呢,一定是你要在临界区里面做检测,你一定要先进入临界区,访问我们临界区的判断条件,然后你的IsEmpty或者IsFull()函数不就是访问了我们的阻塞队列吗,这不就是临界资源嘛,怎么办呢,如果阻塞队列是我们对应所称做的临界资源,我们做检测访问,只有你访问了它了,你才能判断条件满足不满足。所以同学,你要判断一个条件是否满足,一定是在我们临界区内部,才能得出条件是否满足不满足,进而才能决定你是否需要挂起等待还是继续运行。这叫做什么,这叫做,你进入临界资源的时候,首先要进行的叫做,条件判断之后,挂起等待,一定是占有锁的情况。你一旦占有锁了,那么你在对应的条件变量下等的时候,你把锁占着,你去条件变量下等了,那谁来释放这个锁呢?你看,当你调用IsFull()函数的时候,当为真要去对应的条件变量下等待,此时你是拿着锁呀,你拿着锁去等待了,消费者可不可能进来,是不可能的。所以,我告诉大家,为什么调用pthread_cond_wait()第二个参数为什么是一把锁,因为该函数,在阻塞我们对应的线程的时候,会自动释放mutex_锁。也就是说,此时我们条件变量把自己挂起等待的时候,线程他自己一定是在临界 里面的,因为它是经过了条件检测是否满足之后得到的结果,所以它在条件检查的时候,一定要访问临界资源,它要访问临界资源,它一定是占有锁的情况,所以当你在对应的条件变量下挂起等待,你想得美,你挂起等待必须得释放锁。所以同学们你知道这里为什么第二个参数是传入一把锁嘛,这把锁就是你想释放的那一把锁。所以阻塞线程的时候,会自动释放传入的那把锁。
》来同学们,当然还有一个问题稍后再说。这叫什么呢,这叫做我们所对应的叫做让生产者进行我们所谓的等待。同样的,我们最终的消费者是不是也可能去等呀。 则我们再来一个ConBLockWait()函数,消费者去等,你应该能理解一个好玩的东西叫做,当消费者想要去进行我们对应的阻塞等待的时候。消费者跑来消费,经过条件判断是否为空,但是你怎么知道是否为空呀,一定是你先要加搜,然后判断队列。因为你不加锁,你是没有资格去访问这个阻塞队列。所以你要先加锁,判断队列是否为空,如果为空了,你才需要被阻塞等待,同样的,你在被阻塞等待的时候,你也是申请过锁的 ,你申请锁的时候,你要把自己挂起等待了,你就直接把自己挂起等待了?那么此时你也是要释放锁的。所以你ConBlockWait()函数里面调用pthread_cond_wait()函数的时候,第一个参数自己对应的条件变量,&concond_,在该条件变量下等待同时,你还要把占有的这个共享的锁要释放掉。因为,你要维持生产和消费的关系,321原则,mutex_互斥锁维护的就是互斥关系,然后两个条件变量成员变量,维护的就是同步关系,这就叫做互斥且同步的关系。所以该函数的第二个参数是共享的一把互斥锁。所以消费线程被阻塞的时候,也会自动释放锁。说到这里,我们再回过头来看看代码,当我们生产者去生产时,它先去锁住队列,然后去判断条件,如果队列满了,那么我就不能去生产了,那我怎么办,那我就去挂起等待,然后还要把锁释放,锁一释放,就阻塞等待了,那谁就很happy呀,那是不是消费者。消费说,诶,有锁了,我赶紧去申请锁,然后去加锁,加锁之后,去判断条件,此时队列有数据,然后去消费数据,消费完之后就解锁。所以生产者挂起等待一旦把我们的锁释放,那么其他线程就顺理成章的得到锁,进而进行对应的消费。
》我们再继续,那么如果此时,消费者,比如说它曾经因消费,队列为空,然后把自己挂起等待阻塞了,阻塞之后怎么办呢,那么此时这里的消费者呢,它当前阻塞了,那么生产者进行加锁队列,然后最后释放锁。没有人跟我抢,那我们就申请锁,锁住队列,锁住之后我们就判断,判断当前队列是否为满,若不满则生产,生产完了,那么同学们,在我解锁的时候,数据已经有了,那么我们消费者知道吗?消费者不知道,为什么不知道呢,因为消费者觉得,咦,我现在也没有,因为我现在在我的条件变量下等待着 没有人唤醒我呀,所以换句话说呢,你把锁释放了,可我不知道,我现在没在锁上等,我现在是在条件变量下等,所以,同学们,对我们来讲,生产线程把数据生产满了,那么生产者也去挂起等待了,它也去挂起等待了,那么此时数据已经有了,消费者压根就不知道有数据了,它没法去消费,那怎么办呢,所以我们还得来封装一个函数WakeUpCon()唤醒消费线程呀。同学们,想想,是不是我们此时,就要唤醒,你生产了一批数据,你其中就应该唤醒我们对应的消费者,对不对。再下来,当你进行我们对应的解锁之后呢,你不是消费走一了一批数据吗,那么你也要有WakeUpPro()函数,把我们对应的生产者唤醒。当我消费者把数据消费走了,我只要消费了一次,那么一定有一个空位置,赶紧让我们对应的生产者来进行生产。所以我们需要有两个WakeUpCon和WakeUpPro函数去唤醒对应的线程。那么,怎么去唤醒我们的生产者呢,那,生产者在哪里去等呢?谁唤醒生产者呢,是不是我们的消费者去唤醒生产者呀,别睡了,起来了,我刚刚消费走了一个数据赶紧给我在生产。生产者给我们消费者说,别睡了,赶紧来消费吧,所以这里叫做唤醒生产者和唤醒消费者。唤醒生产者,那么是谁唤醒,一定是我们的消费者。怎么去唤醒呢,你想想,唤醒生产者呢,我们生产者在哪里等呢,生产者不就是在我们的对应的自己条件变量下等,那么唤醒生产者一定是在特定条件变量下。所以WakeUpPro()函数里面调用pthread_cond_signal(&procond_)函数,此时我们是不是就可以唤醒在该条件变量下等待的线程。同样的,我们要唤醒我们对应的消费者,那怎么办呢,pthread_cond_signal(&concond_)函数,那消费是不是也在自己的条件变量下等呀。那你再唤醒它就可以了,所以这就叫做唤醒生产者和唤醒消费者。因为你们曾经都在各自,每个人都自己的条件变量,你在自己的条件变量下,我们称作阻塞的时候再来唤醒你的时候在你的条件变量下唤醒你。而唤醒你的调用方呢,其实是对方在调用对方。比如说,生产者唤醒消费者,消费者唤醒生产者。这个代码呢,我们基本写完了,但是呢,我想给大家说的是,这些代码里面呢,还有一些,谈不上问题,但有些细节我们要雕琢,除了bq_.push(in)似乎有些问题了,但是呢,当然我们也可以把生产数据函数封装一下,来一个 void PushCore(const T& in)函数,里面调用bq_.push(in)函数。然后还有一个就是消费,消费这里,我们此时要做的就是T PopCore()函数,然后pop的时候怎么去pop呢,就是T tmp = bq_.front();bq_pop()函数,然后我们返回return tmp;就行了。
》有了这么个简单的队列,我们做一个简单的测试,这份代码还有一点问题,我们稍后再说,不多说,我们main()函数里面,BlockQueue bq;bq.push(10); int a = bq.pop();cout << a << endl;我们简单测试了一下没有问题。我们这个代码是有小问题的,我们稍后再改。稍后再讲的就是与我们知识条件变量强相关到的内容了。必要的封装还是需要的,全是原生的不太好。我们测试怎么测呢,先来一个BlokcQueue bq,我们创建两个线程pthread_t c,p;然后调用pthread_create(),生产线程的回调函数是Productor(),消费线程的回调函数是ConSum()函数;然后紧接着就是pthread_join()函数回收等待新线程。我们写的阻塞队列支持多线程的,因为它本身是一个生产者,一个消费者,他们得先去竞争锁,锁已经保证了它们一定是互斥的,多生产者和多消费者都可以,我们分别用一个线程来测试,这样不会乱。我们来写回调函数,void* Productor(void* args); void* Consummer(void* args);我们先来写生产线程,我们生产线程呢一定是循环做一件事情,我们种一颗随机数种子,在main()函数,写srand((unsigned)time(nullptr));光是有时间的话随机数还是不那么强,我们再或上(^)getpid();即srand((unsigned)time(nullptr)) ^ getpid();接下来在生产线程的回调函数中,while循环里面,先制作数据,同学们,我这里故意用了一个词叫做制作,而不是生产,因为这里也是一个知识点,但这个知识点很好理解,但是有很多人没点透。第二个我们才叫做生产哈,那么制作数据怎么制作呢,那就是 int data = rand()) % 10;我们接下来就是生产,生产怎么生产呢,此时我们就得在while循环前,获取阻塞队列,BlockQueue* bqp = static_cast<BlockQueue*>args,强转成我们想要的类型。然后我们生产数据就是bqp->push(data),cout << “productor 生产数据完成:” << data << endl;然后,我们调用sleep(2),这个我们让它生产慢一些,因为我们是阻塞队列,我们生产慢,那么我消费者不做任何的时间控制的话,那么你消费者必须按照我的生产节奏来走,这跟我们前面讲管道一样,如果我写的慢,那么你读的也得慢,因为i你必须得有访问控制,你得有控制同步机制。不能说我生产的很慢,你消费的很快。接下来在,消费线程的回调函数Consumer()里面,一样先在while()循环之前,获取阻塞队列, BlockQueue* bqp = static_cast<BlockQueue*>args,然后在while循环里面,int data = bqp->Pop(),获取数据,cout. << “consumer 消费数据完成:” << data <<endl;
》接下来我要看到的现象就是,我生产的慢,你消费者必须按照我生产者,我给你什么,你拿什么,我不给你,在我休眠的两秒期间,队列是没有数据的,那么你消费线程必须得是被阻塞住,也就是你的consumer不能疯狂获取数据了,你应该阻塞住,等我给你去生产,怎么办呢。我们先来测试一下代码,此时我们可以看到,生产者生产一个,我们消费者就消费一个了。接下来呢,我们把生产现场的slepp()注释掉,让消费者消费的慢点,调用sleep()函数,如果我想让消费者慢一点,让我们生产者疯狂去生产没有控制,你可以假想一下,如果此时我们的阻塞队列现在数据容量最大是5个,其中呢,我们先让生产者去生产,所以刚开始,无论是消费者还是生产者,谁先运行呢?假设消费者先运行,它一检测到队列为空 ,他直接被挂起阻塞在那里,那么此时生产者就开始生产了,当生产者进行生产的时候,没有人拦着他,它使劲的在生产,但是你应该看到的是,它生产的时候,要一瞬间就把我们对应的阻塞队列生产满,满了的话,对不起你不能再进行生产了,你就应该停下来,那我们消费者它每隔两秒才拿走一个,所以一会儿我们看到的现象就是,一瞬间,生产者把数据给我们生产满了,满了之后,我们让消费者给我们消费一个,消费一个之后呢,我们生产者才再去生产一个,所以就变成了消费一个,生产一个… 但第一次的时候,因为时序的问题,或者说是谁先运行我们不确定,所以大概率会看到其他现象,稍后再说。我们再对代码进行编译运行,可以看到一瞬间就生产满了,生产了5个数据,然后呢,消费者消费了一个,生产者再生产一个…此时它们两个通过生产和消费的逻辑阻塞同步起来了。目前来看,我们代码没有问题,能够跑起来,但实际上,我们的代码还有小问题。
》来,我们把问题说一下, 这叫做阻塞队列,稍后还有一些内容,我们为什么要用模版呢,有我自己的道理。下面呢给大家说一下,其中呢,我们的生产和消费挺好的,现在也能跑起来,我们觉得没有问题,但是这里面有一个什么问题呢?第一个,我们先解决刚刚的阻塞等待问题。你刚刚给我说过这样一句话,当我们某一个生产者或者消费者,在属于自己的条件变量下等待的时候,它第一个工作,因为它在等待的时候,一定是在临界资源里面,同学们,想想我们写的代码,是不是在临界区里面阻塞等待呢?那么你可是拿着锁去等呀,你是加锁没有解锁呀,拿着锁在这里等的时候,你若是挂起等待了,锁只有一把,此时就出现,你把锁抱着去等待了,那怎么办呢,我们说了这个地方pthread_cond_wait(),它在进行被调用时,它会自动释放mutex锁,自动释放mutex锁是原子性的操作,释放之后呢,你等待了,释放锁,另外一个线程就可以申请锁,然后用这个锁去进行消费了,这是其一。其二是,可是当我醒来了呢?别忘了,我在哪里等待,醒来的时候就会在该处醒来。所以我在ProBlockWait()处等待的,那我就会从此处醒来继续向后执行,这个太重要了。首先我们说过,你去等的时候,你为什么去等呢,一定是条件判断来决定的,你等的话,一定是在临界区去等的,你在等的时候,你不能拿这锁等,所以你调用的pthread_cond_wait()会自动释放锁。但是,当我醒来的时候,此时我一但醒来了,难道是从头开始执行吗,不是的,你在哪里休眠的,就在哪里醒来。所以这里就有几个问题,第一个问题,别忘了,当你醒来的时候, 我是在临界区里面醒来的!!在临界区里面醒来的,但是我在进入休眠的时候,我是把锁释放掉了,当我醒来的时候,那么此时是不是就有安全问题呀,如果没有持有锁的话 ,在休眠的地方醒来,向后执行代码是不是有安全问题呀!是不是就相当于没有锁就去访问临界资源了。你所考虑的问题,人家接口全部都给你考虑好了。当阻塞结束的时候,返回的时候,pthread_cond_wait(),会自动帮你重新获得mutex_锁,然后才返回!也就是说,这个函数给你设计的时候,就想好了,当你挂起的时候会自动给你释放,当你醒来的时候,会自动帮你获取这个锁,然后才会返回。这就是pthread_cond_wait()函数,你只需要调用一下它,后续不用管,当你醒来的时候,再往后执行你是持有锁的。所以,我们才在课件里面刚刚在讲的时候,写了一个叫做错误的例子,就是,你呢在进行等待的时候,你先解锁,然后醒来的时候,紧接着下面调用pthread_mutex_lock()加锁,此时这就叫做画蛇添足,错误的事情,你不需要再调用pthread_mutex_lock()加锁,因为pthread_cond_wait()函数会自动帮你做。同学们,这就是线程库,你想想,人家给你设计的时候,作为设计者早就给你考虑过这个问题了。🚩🚩🚩🚩所以,同学们,为什么前面写的代码我们说残留下来一个问题,后面解决,就是现在来解决,想要批量退出线程的时候,发现无法退出!为什么呢,原因是因为,我们调用pthread_cond_wait()后,我们再唤醒线程后,它会自动给你获取锁,注意你可不是只有一个线程,你是有4/5个线程呢,你四五个线程都要被唤醒的时候, 此时四五个线程就要重新去竞争这把锁,同时被唤醒,就不会卡死了,同时唤醒,大家互相竞争这个锁,只有一把锁,你四五个线程只能竞争到一个,所以上次看到只有一个线程退出,最后剩下的线程竞争不到锁,在锁这边也就被阻塞住了,此时其他线程没有办法去退出了,而且你那个进程调用完了,也就是释放了,释放之后,你那把锁也没释放这个锁,所以最后就导致其他线程无法去申请到锁,最后导致的就是你的线程无法退出。那解决上面的问题,就是在你使用pthread_cond_wait()的时候,之前加锁和之后解锁,就自动给你解决了。
》同学们,这就完了吗,还没有,我们还差一口气。现在呢,我清楚了,在他之前和之后会自动释放和申请到锁,这都没问题。但是,我被唤醒了 == 条件被满足吗?我作为一个线程,我为什么会被阻塞等待,是因为,我当前的阻塞队列为满了,我不能再生产了,好,我不生产,挂起就挂起,我阻塞之后最后被唤醒了,唤醒的时候,是不是一定是,这里的条件满足对生产者来讲,一定指的是可以生产了么?换句话说,我被唤醒了,一定是这个队列当前已经不为空了, 我们可以在下面直接生产了。同学们,是不是呢?也就是说,一个线程只要被唤醒,就一定表示队列不为满了,可以往队列里面塞数据了。这两个等价吗?从目前来看,是等价的。但是,同学们,有这样的情况,同学们,pthread_cond_wait()是函数吗?是函数的话,就有可能会调用失败,如果这个函数调用失败呢?调用失败没有wait成功,没有wait成功的时候,想一想,如果我当前去等了,调用了pthread_cond_wait()函数,调用失败,没有让我去wait成功,我没有阻塞住,那么我继续就会向下执行,同学们这是其一。其二,我阻塞成功了,但是因为某些条件,它把我伪唤醒了,所谓的伪唤醒,比如说我自己,因为系统当中给我误发了某些锁对应的唤醒逻辑,或者说呢其他情况未知原因导致我们的这个线程只要阻塞等待,你就有可能被唤醒,但是一种假唤醒,可能是因为条件没有就绪就导致被唤醒,那么此时我继续向下执行,可是我继续向下执行的话,队列依旧是为满的, 我不能再生产。第三种,今天我们只有一个生产者,而如果有5个生产者么,5个生产者此时可能都在对应的条件变量下阻塞等待,当然我们后面再WakeUp()的时候,把5个线程都唤醒了,5个线程全部被唤醒的时候,每个线程被唤醒,假设其中一个线程被唤醒,那么它当前可能。算了,不用多线程了,可能不好想。我们就问一句话,只要ProBlockWait()函数被调用的时候,即它其实是调用的pthread_cond_wait()调用失败的话,没有被挂起,没有被阻塞,那么它是不是可能继续向后执行了, 这是其一。其二,我们此时再多线程当中,一个线程被挂起 ,它可能会被伪唤醒,可能当前的条件根本没有满足,而我不知道,无意间收到了一个系统给我发的或者是一些其他莫名原因而导致我自己的线程从挂起阻塞状态被唤醒,唤醒之后,我可能继续向后执行,但是条件并不满足。所以被唤醒并不等于条件满足!同学们,虽然说这个概率虽然很小,但是呢,我们也得考虑这个情况。也就是说当我阻塞的时候,突然出现一些原因发生了伪唤醒,此时呢,条件并不满足而导致此时伪唤醒之后,我继续向后执行,我又push此时就完蛋了,这个队列里给别人多塞数据,或者说把从前的数据给覆盖掉了。这个情况我们此时要解决,我们不能使用if判断,我们要保证,你即被唤醒,条件又要被满足,我们要做到的是,你的两个条件同时满足,即唤醒和队列数据条件满足。怎么做呢?其实很简,我们只要把if改成while循环,因为while本身具备条件检测能力!你被唤醒了,先别着急往下执行,先继续去判断条件。只有条件判断让你可以继续向后执行了,你才可以退出循环,这样才能保证,第一,你被唤醒了;第二,你的条件满足了;这样你才可以向下执行下面的代码。这样的代码写出来健壮性才会很强。同样的消费线程也是一样的,要改成while循环来判断条件。
》下面第三个细节,有些同学可能还有问题,什么问题呢,我LockQueue()叫做加锁,UnLockQueue()叫做解锁,然后最后在解锁之后呢就是WakeUp()函数唤醒。但是有些书或者教材呢,会将唤醒放在解锁之前。那究竟是在临界区内部,比如说我临界区内部的核心工作都做完了,是在内部唤醒消费者呢,还是在外部唤醒消费者,我们还是以生产线程为例子。如果放在内部,你想想,我把核心工作都做完了,我的生产完成,然后我就把消费者唤醒,唤醒的时候,我接下来就是解锁,但是当你唤醒的时候,要么在任何地点可能被切走切换,当你在WakeUpCon()切走之后,然后你没有释放锁,没有释放锁怎么办呢,你的消费者就会唤醒,从条件变量下唤醒,他会去争锁,但他争得到吗?没有!那它怎么办,是不是继续阻塞等待。此时它现在不会在条件变量下等待了,而是在互斥锁上面等待了。同学们,所以你一旦解锁之后,我们对应的消费者也会竞争锁成功,这个是不影响的,所以放在临界区内部是个可以的。当然,有同学可能会说,拿放在外面呢?我们把数据已经push进去了,然后我就是解锁,解锁之后再唤醒消费,那么我一旦解锁了,唤醒消费者,消费者立马能够从pthread_cond_wait()返回,并且把锁重新申请到,接下来进行后续操作,它来消费了,这个没问题。可是在任何地点都有可能会被切换,如果我刚开始把锁释放,我还没有唤醒消费者呢?那么此时其他消费者可能会把数据拿走,拿走之后怎么办呢,不影响,拿走之后,其他消费者忙自己的,后面还要执行消费唤醒,唤醒某个消费者再唤醒之后怕不怕呢,不怕。为什么呢,因为一旦唤醒了,能唤醒的,说明已经把锁重新拿到了,没有唤醒,此时你没有唤醒成功,比如说竞争锁失败了,那么它就不会在条件变量下等了,而是在我们的互斥锁上面等了,不影响,此时一旦锁被释放,它可以自己去竞争。另外一旦你把它直接唤醒了,那么其中当前呢,如果唤醒成功了,那么此时还会做判断的,有可能该数据呢在你解锁的时候就有其他线程已经把数据拿走了,然后你这个消费者被WakeUpCom()函数被唤醒了,是要进行在while循环里面再做条件检测的。所以条件检查,如果有数据就拿,没有数据就会继续执行ConBlockQueue()函数。所以同学们放在里面和放在外面是一样的,是不影响的。如上就是阻塞队列的三个小细节。其中呢,可能让同学们费点脑子的就是唤醒消费者WakeUpCon()函数是放在临界区内部还是外部要费点脑子的。
》下面呢,我们可以做两件事情,第一件事情,把课件过完;第二件事情,然后我们再说一下,我们如何重新回过头,重新理解生产者和消费者模型。我们也要通过代码的方式给大家吧生产和消费再给大家进行介绍一下。之前是理论,现在是实践。但是理论和实践是脱节的,虽然我们能看到它是生产和消费,但是呢它依旧是脱节的,我们把它再给大家揉一揉。

Makefile:
/*
BlcokQueue:BlockQUeue.cc
	g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
	rm -f BlcokQueue*/------➡️老式的Makefile写法

CC=g++
FLAGS=-std=c++11
LD= -lpthread
bin=blockQueue
src=BlockQueueTest.cc
 	
$(bin):$(src)
 	$(CC) -o $@ $^ $(LD) $(FLAGS)
.PHONY:clean
clean:
 	rm -f $(bin)
BlockQueue.hpp
#include <iostream>
#include <deque>
#include <pthread.h>

using namespace std;

const uint32_t gDefaultCap = 5;

template<class T>
class BlockQueue
{
public:

BlockQueue(int cap = gDefaultCap):cap_(cap)
{
    pthread_mutex_init(&mutex_,nullptr);
    pthread_cond_init(&procond_,nullptr);
    pthread_cond_init(&concond_,nullptr);
    
}

void Push(const T& data)
{
    //加锁
    LockQUeue();
    //判断-->是否生产-->bq是否为满--->程序员视角的条件--->1.满(不生产);2.不满(生产)
    /*if(bq满) 不生产
    eles if(bq不满)生产,且唤醒消费线程
    */
   
   while(IsFull())//得换成while循环,循环判断出才行
                  //IsFull()就是我们在临界区中设定的条件
   /* if(IsFull()) */
    {
        //before:当我们等待的时候,我会自动释放mutex锁
        ProBlockWait();/*阻塞等待,等待被唤醒。被唤醒 == 条件满足吗?(可以生产了吗?)===>不可以!
                        因为会有系统等莫名原因被伪唤醒。要 被唤醒&&条件满足才能继续向后执行*/
        //after:当我醒来的时候,我是在临界区中醒来的!
    }
    //条件满足,可以生产
    //生产数据
    PushCore();
    ConWakeUp();//唤醒消费线程消费---->该函数放在临界区内部还是外部都可以
    //解锁
    UnLockQueue();
    /*ConWakeUp();//唤醒消费线程消费*/

}

T Pop()
{
    //加锁 
    LockQueue();
    //判断-->是否适合消费-->bq是否为空--->程序员视角的条件--->1.空(不消费);2.不空(消费)
     /*if(bq空) 不消费
    eles if(bq不空)消费,且唤醒生产线程
    */

   while(IsEmpty())
  /*  if(IsEmpty()) */
   {
        ConBlockWait();
   }
    //消费数据
    T tmp = PopCore();
    //解锁
    UnLockQueue();
    ProWakeUp();//唤醒生产线程生产

    return tmp;
}

~BlockQueue()
{
    pthread_mutex_destroy(&mutex_);
    pthread_cond_destroy(&procond_);
    pthread_cond_destroy(&concond_);
}

private:

LockQueue()
{
    pthread_mutex_lock(&mutex_);
}

UnLockQueue()
{
    pthread_mutex_unlock(&mutex_);
}

bool IsFull()
{
    return bq_.size() == cap_;
}

bool IsEmpty()
{
    return bq_.empty();
}

void ConWakeUp()
{
    pthread_cond_signal(procond_);
}

void ProWakeUp()
{
    pthread_cond_signalO(concond_);
}

void ProBlokcWait()//生产者一定是在临界区中的!
                   //阻塞等待,等待被唤醒
{
    //1.在阻塞线程的时候,会自动释放mutex锁
    pthread_cond_wait(&procond_, &mutex_);
    //2.当阻塞结束,返回的时候,pthread_cond_wait(),会自动帮你重新获得mutex_锁,然后才返回!
}

void ConBlockWait()
{
    pthread_cond_wait(&concond_, &mutex_);
}

void PushCore(const T& data)
{
    bq_.push(data);
}

T PopCore()
{
    T tmp = bq_.front();
    bq_.pop();
    return tmp;
}

private:
    queue<T> bq_;//阻塞队列
    uint32_t cap_;//容量
    pthread_mutex_t mutex_;//保护阻塞队列的互斥锁
    pthread_cond_t concond_;//消费者条件变量
    pthread_cond_t procond_;//生产者条件变量
};

BlockQueue.cc

》课件:
〉》// 错误的设计 pthread_mutex_lock(&mutex); while (condition_is_false) { pthread_mutex_unlock(&mutex); //解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过 pthread_cond_wait(&cond); pthread_mutex_lock(&mutex); }pthread_mutex_unlock(&mutex);
〉由于解锁和等待不是原子操作。调用解锁之后, pthread_cond_wait 之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么 pthread_cond_wait 将错过这个信号,可能会导致线程永远阻塞在这个 pthread_cond_wait 。所以解锁和等待必须是一个原子操作。(你像你这个上面代码为什么是设计错误的呢,因为当你解完锁之后,你可能被切走了,被切走之后,其他线程把锁拿到了,拿到之后呢,其他线程就是继续做自己的事情,而你因为解锁去等待,等待的时候呢,将来你在对应的条件变量唤醒的时候,可能把其他线程唤醒了,而导致你错过了将你唤醒的信号,会导致你这个线程永久阻塞在pthread_cond_wait()这个地方了。所以这个pthread_cond_wait()接口已经给我们设计好了, 解锁和挂起等待是一个原子操作,并且该函数是会自动帮你释放锁和申请锁的!)
》int pthread_cond_wait(pthread_cond_ t cond,pthread_mutex_ t * mutex); 进入该函数后,
会去看条件量等于0不?等于,就把互斥量变成1,直到cond_ wait返回,把条件量改成1,把互斥量恢复
成原样。
》###条件变量使用规范:
等待条件代码
pthread_mutex_lock(&mutex); while (条件为假) pthread_cond_wait(cond, mutex); 修改条件 pthread_mutex_unlock(&mutex);
》所以我们是条件变量范例呢就是,加锁和解锁,中间这里用while()条件,然后pthread_cond_wait()函数。
》给条件发送信号代码
pthread_mutex_lock(&mutex); 设置条件为真 pthread_cond_signal(cond); pthread_mutex_unlock(&mutex);
》然后发送信号呢,就是加锁,发送信号和解锁。课件呢是把信号写在加锁和解锁之间,当然你也可以写在解锁后面也行。
》如上就是我们条件变量的内容,下面我们给大家重新理解一下生产和消费者模型。所以,从我们刚刚条件变量来讲呢,它是什么意思呢,IsFull()等它是条件,然后ProBlockWait()就是我们说的条件变量。就是IsFull()等是我们程序员维护的,ProBlockWait()是我们程序员发现条件不满足,使其挂起。
》下面我们再重新结合我们的代码去理解一下生产与消费者模型。现在呢,想给大家理解呢,不想再给大家说一些其他话呢,现在重点想给大家理解一下,你拿数据,生产者生产数据,消费者消费对应内容 ,在交易场所里,比如我们前面写的代码中的交易场所就是我们的BlockQueue,生产者和消费者就是两个线程。其中在交易场所里依旧是互斥的。同学们,依旧是互斥的呀,你虽然有同步,但是你也有一把锁,它们是互斥的呀。那你生产者和消费者它是叫做并发,请问你所谓的并发体现在哪里呢?你不是给我讲生产者和消费者模型吗?可是我生产数据和消费数据拿的时候,那么它是互斥的,那你并发体现在哪里,你不是多线程吗。同学们,下面来给大家说一下,接下来做一个什么工作呢,我们把我们上面的生产者和消费者模型代码继续给大家改写一下,为什么要继续延用上面的呢,原因就是,既然我们当前可以构建一个我们所对应的int类型的数据,我们也可以使用叫做自己封装的类型,包括任务。。同学们,阻塞队列既然能放int,double等,当然也可以放封装自己的类型,定义自己的对象咯,包括任务。
》今天我想用消费和生产者模型,我想用它做一个叫做计算任务,那么生产者用来做计算任务,消费者用来消费计算任务,那怎么办呢?那此时我们得再加一个task.hpp。我们定义一个Task类型,今天呢,我的任务是加减乘除,那么加减乘除呢就来一个int elemenone_,int elementwo,char operator_代表加减乘除里面某一个运算。所以呢,我自己想写一个任务,这个任务呢可以被别人执行,怎么办呢,我们先来写构造含糊,我们写两个,一个叫做默认构造函数,elemenone_,int elementwo ,operator_默认都给0,然后我们再来一个自定义构造函数,Task(int one, int two, char op)当然我们其实也可以带全省参数,就不用再写前面一个默认构造函数了。有了任务之后接下来怎么办呢,既然有了任务,我们再封装一个run()函数,再run()函数内部定义一个result变量,int result = 0;然后在其内部用switch,case语句。我们要考虑到除法的时候,不能有除0,若有除0,我们就return -1,若是改成result = -1的话,若消费线程拿到一个-1,是计算结果是-1还是我们除0了,所以实际上呢,想要这样的话,我们得在Task类里面再加一个成员变量int code;来标识是否出错,不过不想写了,所以我们就干脆result = -1然后会打印一句话那就是告诉我们除0错误了。当然我们可以继续改写,叫做int operator() ()然后在其里面直接retrun run就可以了,此时我们就相当于把Task类型设计成了仿函数,当然你不设计也可以。所以呢,我们就是设计了一个任务类,这个任务类呢可以帮我去构建对象,并且还能够处理。怎么呢,有了任务呢,我们再来进行,在BlockQueue.cc里面包Task.hpp文件。我们前面能够建立整数对应的阻塞队列,那么我今天当然也可以构建一个Task类型的阻塞队列。同学们,那么我们是不是构建了阻塞队列式的里面放的是一个一个的任务。一旦变成任务了,我们此时我们BlockQueue.cc里面的代码我们不用变,生产者消费者此时线程的回调函数中的bqp,模版应该变成Task类型。生产者你不是要生产任务吗,怎么生产呢,我们制作任务,怎么制作呢,我们Task t()对象里面我们传的参数是int one = rand()%50;int two = rand()%20;这是我们随便写的,我们的operator干脆来一个全局const string ops = "±
/%";所以我们定义了一个加减乘除取模的运算,然后我们char op = ops[rand() % ops.size()],然后再试Task(one , two , op); 然后就是bqp->push(Task);此时消费线程Task t = bqp->Pop();就将任务拿出来了。其实我们在BlockQueue.hpp里面的Pop()函数里面稍微添加一下,tmp.run();这样的话是不是相当于就把任务处理了,但是呢,我们不想这么干,我们处理任务的过程不属于临界区当中的内容。最多你要写,你也得放在临界区外面,但我今天照样不把它放在Pop()函数里面,阻塞队列的任务就是帮我放任务和拿任务,任务是什么样子,怎么处理,跟你没关系。所以此时,我BlockQueue.cc里面consumer回调函数拿到任务后,直接t(),t()是什么呢,我们刚刚在Task类里面构建了一个仿函数,所以t()会直接调用run()函数,所以t()叫做我们的处理任务。Task t = bqp->Pop()是消费任务。
》我们此时编译并运行,跑起来之后呢,有3个线程,主线程什么都没干,然后新线程就开始我们对应的处理了。同学们,这叫什么,这就叫做基于阻塞队列可以让我们的线程直接run起来哈。当然未来你可以再创建线程让它们跑一跑。我们讲这个的意义是为了让大家知道,我们讲的阻塞队列是有意义的,阻塞队列呢是可以一个线程给另一个线程派发任务的。任务我们现在不是有了吗,以前我们在讲管道的时候也能派发任务,但是我们当时的任务只能是一个整数或者是我们需要自己去定义格式的数据,但是线程不一样,资源共享,我们用一个vector或者queue我们就可以让我们的多线程之间互相通信了。 这就是为什么在企业里面大部分都是线程开发的,因为它很简单。
》 接下来第二个目标,你还是没有给我回答刚刚的问题,什么问题呢,你还没是没有回答,你生产和消费是互斥的,你怎么体现并发的。下面我想给大家说的是,很多教材里面不会给大家写代码,生产和消费者模型不会给你写的,他最后只会告诉你生产和消费者模型是什么,我们刚刚写了一个。但是呢,很多教材再进一步,你写完了就结束了。但是实际上有一个问题一定要交代清楚,同学们,生产和消费的过程确实是从我们的共享资源里面进行拿任务和放任务的过程。但是,你记住了,请问制作任务要不要花时间?这是第一个。第二个,叫做我们对应的,处理任务要不要花时间呢?同学们,不要狭隘的认为,消费者和生产者模型就是把数据或者任务放到队列里,然后你来拿,这叫做生产者和消费模型,不仅仅如此,那么其实呢当你在进行向队列里放任务的时候,可能这个消费者正在处理任务。所欲生产者和消费者模型在这里确实是串型的,你是有锁的。但是,生产者在生产的时候,和消费者在消费任务的时候,他们两个是并发执行的。所以生产和消费者模型里面,不要认为就是把数据放进去拿出来就完了,并发特性并不是体现在交易场所里面。所谓的生产者和消费者模型支持并发,并发并不是在我们临界区并发,它们也没法并发,它们是互斥的,我们下面的例子可能出现并发的情况,但是它不是主要矛盾。同学们,把数据放到缓冲区和从缓冲区里面拿出来费时间吗,一定是不费时间的,有人说是串型的,串型和你把数据放进去,这是串型带来的时间成本还是你放数据带来的时间成本,一定要搞清楚。我们把数据放到缓冲区里面不费时间的。所以此时,我们并发并不是在临界区当中的(一般,我们下面一个例子会是在临界区的当中的)但是我们现在要意识到,一般并不是在临界区并发,而是生产前和消费后对应的并发。生产前指的是你把数据放到队列之前,消费后呢,指的是你把数据从我们阻塞队列拿出来之后,那么你这个时候进行处理,所以呢,生产者生产前有没有人告诉你任务是从哪里来的,我们今天写的任务,当前十我自己给你随机生成的,未来我们要的任务可不可能从网络里面来呢,或者磁盘里面来呢,或者用户里面来呢,你看,网络、磁盘、用户哪一个不慢,所以你此时拿任务的时候,在你没有任务的时候,我们是不是曾经在我们的阻塞队列里 保存了一份余粮让消费者去消费。所以呢,消费者在消费期间,我生产者可能正在生产任务,这个相当于,你消费的同时我在生产,并发是体现在这里。同样的,你不是处理任务吗,我们的任务简单,就是计算,如果未来想磁盘,写网络呢,写大型的数据压缩呢,那么任务也是要花时间的!就好比我今天说,现在的任务不再是加减乘除了,你帮我写一个打印日志的内容,要访问磁盘。打印日志的话,你就可以再写一个Task,这个内部的方法,参数就是你要写入的string,然后当有任务的时候,我们可以把任务构建成string构建成任务派发给线程,而另一个线程呢,去帮我写磁盘,我自己在进行对应的处理。所以,我们处理任务的时候是不是也要花时间,所以我在处理任务期间,生产者它可以不断的给我制造任务,我来不及给他处理了,它可以暂时放在我们的阻塞队列里,这才叫做我和我们的生产和消费实现并发。所以我们的课件或者教材说,生产和消费者模型支持解耦、支持并发和忙闲不均,那么你一看这不对呀,我自己手写了一个生产和消费模型,在临界区你不是加锁了吗,那你并发是体现在哪里呢,还是我生产一个你消费一个,那么这里你怎么体现呢,其实最重要的是生产前和消费后你们两都在干什么体现出并发,那么它的解耦体现在哪里呢,体现在我生产者生产出来的任务,我可以吧任务通过仓库派发给你,生产和消费没有见过面,只知道生产者把生产的任务放到仓库里,自然而然的就会有消费者从仓库里面拿。如果此时,是函数调用的话,我们是不是必须得构建一个任务,把这个任务在传递给函数,这叫做阻塞式调用。我们现在把生产和消费解耦了。然后忙闲不均就更不用说了,忙闲不均就比如说,这里的忙闲不均到底是指的什么忙闲不均呢,叫做制作任务和处理任务叫做忙闲不均。你在仓库里面拿所谓的任务时,必须得是单线程去拿,因为我们要保证线程的安全嘛,所以忙闲不均相当于我制作一个任务 花一秒钟,对方消费一个任务要花10秒钟,那么此时我就可以在生产期间多搞几个生产者, 多多的去制作一些任务然后放到仓库里面,这叫做忙闲不均,而不是我们在仓库这里拿来拿去怎么样。
》如上就是我们的关于C++对应的queue来给大家模拟实现我们所对应的BlockQueue这样的生产和消费模型,这样的代码全部写完。后面呢,可以给大家布置一个小任务,把代码改改,这也是我们将代码做封装的原因,我们刚刚呢叫做阻塞队列,下面我呢,想把它改成, 新需求:我只想保存最新的5个 任务,如果 来了任务,老得任务我想让他直接被丢弃。就是我现在呢只想保存最新的5个任务,我不会阻塞了,如果你来了第6个任务,我会把曾经最老的任务给取消掉,然后我的队列里面最多只会保存最新的5个任务。

Task.hpp:
pragma once
 2	
 3	#include <iostream>
 4	#include <string>
 5	
 6	class Task
 7	{
 8	public:
 9	    Task() : elemOne_(0), elemTwo_(0), operator_('0')
 10	    {
 11	    }
 12	    Task(int one, int two, char op) : elemOne_(one), elemTwo_(two), operator_(op)
 13	    {
 14	    }
 15	    int operator() ()
 16	    {
 17	        return run();
 18	    }
 19	    int run()
 20	    {
 21	        int result = 0;
 22	        switch (operator_)
 23	        {
 24	        case '+':
 25	            result = elemOne_ + elemTwo_;
 26	            break;
 27	        case '-':
 28	            result = elemOne_ - elemTwo_;
 29	            break;
 30	        case '*':
 31	            result = elemOne_ * elemTwo_;
 32	            break;
 33	        case '/':
 34	        {
 35	            if (elemTwo_ == 0)
 36	            {
 37	                std::cout << "div zero, abort" << std::endl;
 38	                result = -1;
 39	            }
 40	            else
 41	            {
 42	                result = elemOne_ / elemTwo_;
 43	            }
 44	        }
 45	
 46	        break;
 47	        case '%':
 48	        {
 49	            if (elemTwo_ == 0)
 50	            {
 51	                std::cout << "mod zero, abort" << std::endl;
 52	                result = -1;
 53	            }
 54	            else
 55	            {
 56	                result = elemOne_ % elemTwo_;
 57	            }
 58	        }
 59	        break;
 60	        default:
 61	            std::cout << "非法操作: " << operator_ << std::endl;
 62	            break;
 63	        }
 64	        return result;
 65	    }
 66	    int get(int *e1, int *e2, char *op)
 67	    {
 68	        *e1 = elemOne_;
 69	        *e2 = elemTwo_;
 70	        *op = operator_;
 71	    }
 72	private:
 73	    int elemOne_;
 74	    int elemTwo_;
 75	    char operator_;
 76	};
BlockQueue.hpp
#include <iostream>
#include <deque>
#include <pthread.h>

using namespace std;

const uint32_t gDefaultCap = 5;

template<class T>
class BlockQueue
{
public:

BlockQueue(int cap = gDefaultCap):cap_(cap)
{
    pthread_mutex_init(&mutex_,nullptr);
    pthread_cond_init(&procond_,nullptr);
    pthread_cond_init(&concond_,nullptr);
    
}

void Push(const T& data)
{
    //加锁
    LockQUeue();
    //判断-->是否生产-->bq是否为满--->程序员视角的条件--->1.满(不生产);2.不满(生产)
    /*if(bq满) 不生产
    eles if(bq不满)生产,且唤醒消费线程
    */
   
   while(IsFull())//得换成while循环,循环判断出才行
                  //IsFull()就是我们在临界区中设定的条件
   /* if(IsFull()) */
    {
        //before:当我们等待的时候,我会自动释放mutex锁
        ProBlockWait();/*阻塞等待,等待被唤醒。被唤醒 == 条件满足吗?(可以生产了吗?)===>不可以!
                        因为会有系统等莫名原因被伪唤醒。要 被唤醒&&条件满足才能继续向后执行*/
        //after:当我醒来的时候,我是在临界区中醒来的!
    }
    //条件满足,可以生产
    //生产数据
    PushCore();
    ConWakeUp();//唤醒消费线程消费---->该函数放在临界区内部还是外部都可以
    //解锁
    UnLockQueue();
    /*ConWakeUp();//唤醒消费线程消费*/

}

T Pop()
{
    //加锁 
    LockQueue();
    //判断-->是否适合消费-->bq是否为空--->程序员视角的条件--->1.空(不消费);2.不空(消费)
     /*if(bq空) 不消费
    eles if(bq不空)消费,且唤醒生产线程
    */

   while(IsEmpty())
  /*  if(IsEmpty()) */
   {
        ConBlockWait();
   }
    //消费数据
    T tmp = PopCore();
    /* tmp.run(); */
    //解锁
    UnLockQueue();
    /* tmp.run(); */
    ProWakeUp();//唤醒生产线程生产

    return tmp;
}

~BlockQueue()
{
    pthread_mutex_destroy(&mutex_);
    pthread_cond_destroy(&procond_);
    pthread_cond_destroy(&concond_);
}

private:

LockQueue()
{
    pthread_mutex_lock(&mutex_);
}

UnLockQueue()
{
    pthread_mutex_unlock(&mutex_);
}

bool IsFull()
{
    return bq_.size() == cap_;
}

bool IsEmpty()
{
    return bq_.empty();
}

void ConWakeUp()
{
    pthread_cond_signal(procond_);
}

void ProWakeUp()
{
    pthread_cond_signalO(concond_);
}

void ProBlokcWait()//生产者一定是在临界区中的!
                   //阻塞等待,等待被唤醒
{
    //1.在阻塞线程的时候,会自动释放mutex锁
    pthread_cond_wait(&procond_, &mutex_);
    //2.当阻塞结束,返回的时候,pthread_cond_wait(),会自动帮你重新获得mutex_锁,然后才返回!
}

void ConBlockWait()
{
    pthread_cond_wait(&concond_, &mutex_);
}

void PushCore(const T& data)
{
    bq_.push(data);
}

T PopCore()
{
    T tmp = bq_.front();
    bq_.pop();
    return tmp;
}

private:
    queue<T> bq_;//阻塞队列
    uint32_t cap_;//容量
    pthread_mutex_t mutex_;//保护阻塞队列的互斥锁
    pthread_cond_t concond_;//消费者条件变量
    pthread_cond_t procond_;//生产者条件变量
};
BlockQueue.cc
#include "BlcokQueue.hpp"
#include <ctime>

#include "Task.hpp"
#include "BlockQueue.hpp"

#include <ctime>

const std::string ops = "+-*/%";

// 并发,并不是在临界区中并发(一般),而是生产前(before blockqueue),消费后(after blockqueue)对应的并发

void *consumer(void *args)
{
    /* BlockQueue<int> *bqp = static_cast<BlockQueue<int> *>(args); */
    BlockQueue<Task> *bqp = static_cast<BlockQueue<Task> *>(args);
    while (true)
    {
        Task t = bqp->pop(); // 消费任务
        int result = t();    // 处理任务 --- 任务也是要花时间的!
        int one, two;
        char op;
        t.get(&one, &two, &op);
        cout << "consumer[" << pthread_self() << "] " << (unsigned long)time(nullptr) /* 时间戳 */<< " 消费了一个任务: " << one << op << two << "=" << result << endl;
    }
}
void *productor(void *args)
{
    /* BlockQueue<int> *bqp = static_cast<BlockQueue<int> *>(args); */
    BlockQueue<Task> *bqp = static_cast<BlockQueue<Task> *>(args);
    while (true)
    {
        // 1. 制作任务 --- 要不要花时间?? -- 网络,磁盘,用户
        int one = rand() % 50;
        int two = rand() % 20;
        char op = ops[rand() % ops.size()];
        Task t(one, two, op);
        // 2. 生产任务
        /* bqp->push(data); */
        bqp->push(t) ;
        cout << "producter[" << pthread_self() << "] " << (unsigned long)time(nullptr) << " 生产了一个任务: " << one << op << two << "=?" << endl;
        sleep(1);
    }
}

int main()
{
    srand((unsigned long)time(nullptr) ^ getpid());
    // 定义一个阻塞队列
    // 创建两个线程,productor, consumer
    // productor -----  consumer
    // BlockQueue<int> bq;
    // bq.push(10);
    // int a = bq.pop();
    // cout << a << endl;
    // 既然可以使用int类型的数据,我们也可以使用自己封装的类型,包括任务
    // BlockQueue<int> bq;
    BlockQueue<Task> bq;

    pthread_t c, p;
    pthread_create(&c, nullptr, consumer, &bq);
    pthread_create(&p, nullptr, productor, &bq);

    pthread_join(c, nullptr);
    pthread_join(p, nullptr);
    return 0;
}

POSIX信号量

我们终于将我们的线程同步给讲完了,从刚刚的讲法来看呢,我们同学目前很显然对于线程的同步已经有了一种感觉了,我们确实是可以让线程去控制了,刚刚是阻塞队列,下面我们再来谈信号量。
》什么叫做信号量呢?我们曾今是花过大面积去说,信号量是一个计数器,什么计数器呢,是一个描述临界资源数量的计数器,这是其一;其二呢,他这个计数器说白了就是数字,然后它最典型的应用就是“–”和“++”操作。那么**“–”操作对应的是什么呢,它对应的就是我么你的P操作**,然后,“++”操作呢相当于是V操作。说白了就是计数器,–称之为对他的数据做减法,它是原子的!P、V操作必须是原子的,而且在我们–操作的时候,这个代表的是申请资源。V操作也是原子的,它叫做归还资源
》这里我们还要再谈两点:1.信号量申请成功了,就一定能保证你会拥有一部分临界资源嘛?
2.临界资源可以当成整体,可不可以看做一小部分一小部分呢?
》我们先回答一下第二个问题,有若干个线程都想去访问临界资源,这个资源被所有线程共享访问,那么此时我们这个临界资源呢只能进行对他做加锁保护,用信号了和条件变量就可以,但是如果每一个线程,只会访问临界资源的一部分,如果多个线程只会访问临界资源的一部分,那么其中我可不可以让多个线程同时进入临界区的不同区域来完成自己的工作呢?也就是关于临界资源看待的问题,实际上还是要结合同学们的应用场景 ,说白了就是你今天给我写了一大堆 ,如果我们此时有对应的一个线程,它呢可以访问临界资源的某一块,其他线程也是访问某一块,那么我们是可以让多个线程并发的去访问临界资源的,是可以做到的。只有当多个线程只访问临界资源的某一个区域的时候,共同访问我们才再加锁。所以呢,我们第二个问题是可以搞定的,所以临界资源当成整体,是可以被看作一小部分一小部分的。这个可以不可以是不用嘴说的,使用应用场景说的。有人说,你把上面写的阻塞队列给我改成一小块一小块的,做不到好吧,是由应用场景决定的。
》第二个,我们信号量申请成功了,就一定会保证你拥有你想要申请的临界资源嘛?这个我们前面是讲过的,而且为了让大家更好理解,还给大家举过例子,这个例子叫做电影院的例子,我记得是这样说的,同学们,你们在电影院看电影,你想去看电影之前每一个人都得先买票,不是说你占你块头大在上面一坐,位置就是你的,不是的,你得先买票,只要你把票买成功了,是有编号的,你有这个编号了,那么此时你即便不做这个座位,在这个电影结束之前这个位置都是会给你留着的。 同学们,信号量我个人认为理解的最重要的一点不是这么用这个信号量,而是要深刻的理解一句话,叫做,只要信号量申请成功,那么你一定会获得指定的资源。
》我再中断一下,我们互斥锁已经学了,现在我们知道,如果多个线程想去访问临界资源,他们在互斥情况下,我们将临界资源看成整体,都得先申请mutex,然后中间是访问临界资源,最后是释放锁mutex。那么请问申请锁和在执行访问临界资源的时候可能被切换吗?也就是说,我刚申请成功,正准备访问临界资源,我就被切走了,有可能吗?答案是:可能。但是,担心吗?答案是:不担心。为什么呢,因为你知道锁已经被你拿走了,其他人也进不来,其他人进不来那这个资源我知道,所以我得到什么结论呢,只要我拿到了锁,当前的临界资源就是我的。不是讲信号量嘛,怎么又扯到了互斥锁,所以我们每一个人在申请锁的时候,我们把临界资源当成整体,那么,只要我们拿到了锁,我们即便现在还没有去访问所谓的临界资源,我也知道,这货是我的,我没有去访问,我拿着锁这就是我的,同学们,这叫做什么,这叫做资源预定机制。我们对锁的理解应该有一个是,资源预定机制,我们可以申请锁来预定资源,我不释放锁谁也别想访问。那么同样的,我们发现其中这个互斥锁把临界资源当成整体,要么不给你,要么全部给你。那么同样的,我们可以把临界资源划分成一小块,我们可以申请某一部分,当然信号量的初始值可以设为1,初始值设为1,申请的时候呢,P操作进行“–”那么最终就会由1变为0,然后这就叫做我们的申请信号量,可以称之为加锁的过程。我们在V操作的时候,也可以把0变成对应的1,这个过程我就叫做释放锁的过程。所以我们把信号量只有0和1的这种信号量称作二元信号量,等价于我们的互斥锁
》所以呢,你现在想想,概念是统一的, 当你申请信号量的时候,你申请这个信号量,你把信号量-1,你就预定了一个信号量资源,如果你只是二元信号量,我认为就是要将资源当成整体去使用,所以所有线程你们都别进来,你们也进不来,因为锁我拿着呢,信号量我们已经预定了,如果我们保证信号量是多个,其中呢我们就能保证我们的资源,临界资源被划分了不同的区域,所以,每一个线程想要进入一个区域,那么你们得先申请信号量,只要你申请成功了,那么资源就一定是给你了,至于怎么给你,怎么保证你和别人不冲突,这个由程序员保证,这个也是在教材里不会说的,是由程序员保证。你说,这里不访问同一个区域就不访问同一个区域? 你怎么脸这么大,凭什么,所以这是由程序员保证的,为什么我们要把临界区划分成不同的区域呢,我们这个也是有各种场景的。其中呢,我们就知道互斥锁和二元信号量是等价的,如上是信号量的概念,最后结论就只有一个,信号量是一个计数器,用来衡量临界资源当中资源数目。申请信号量的本质叫做,预定某种资源,那么,当我们申请信号量成功的时候,那么这个信号量,对应的那个资源才可以被你唯一去使用,别人不会和你抢,这就叫做信号量。
》之前我们是在什么时候讲的这个信号量呢,之前我们是在,我们进程间通信讲systemV通信当中我们讲过信号量。但是,说过那个地方我们不讲信号量,为什么呢,因为它里面特别复杂,没必要,所以我们今天来了一个POSIX信号量,它呢是可以在进程或线程间共享的,我们今天重点用来做线程间共享。为什么在这里讲呢,原因是它用起来实在是太简单了,很容易就用起来了,所以我们下面来谈一谈信号量。
》systemV信号量和POSIX信号量作用是相同的,都是用于同步操作,达到无冲突的访问共享资源目的,但是POSIX可以用于线程间同步,systemV很难用于线程间同步,再结合我们刚刚所讲的,线程我们用的多,所以他才是我们的重点。
》初始化信号量

#include <semaphore.h>
 int sem_init(sem_t *sem, int pshared, unsigned int value);

参数:pshared:0表示线程间共享,非零表示进程间共享
value:信号量初始值
》销毁信号量

int sem_destroy(sem_t *sem);

》等待信号量
功能:等待信号量,会将信号量的值减1

int sem_wait(sem_t *sem); //P()---➡️它本质就是P操作,对应的就是将信号量计数器设置的值进行-1.

》 发布信号量
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。

int sem_post(sem_t *sem);//V()----➡️对应的就是V操作,相当于把资源使用完毕了,然后归欢资源,然后他呢就将我们的信号量值+1

》上面呢,代码很好理解,接口也很好用,我们稍后会给大家用,我们下面直接再来一个,我们上面讲了生产和消费模型是基于queue的,而queue呢大家都知道,是有头有尾的,你也没法对他做切割,所以我们认为queue是整体被使用的,现在呢我们想做一个可以是一个公共的临界资源,但是这个临界资源可以被划分为不同的区域,这些不同的区域,我们想让他可以直接进行,我们所对应的用信号量把临界区保护起来,所以我们要写一个基于固定大小的环形队列,我们重新再写一个生产和消费者模型。
在这里插入图片描述
在这里插入图片描述

基于环形队列的生产和消费模型

环形数据结构其实是讲过的,今天呢就重点来谈一谈环形队列在多线程当中的一个应用,尤其是和我们信号量结合的这样一个场景。下面呢,不废话先把我们的代码搞起来。我们弄一个RingQueue,hpp,RingQueue.cc。然后就是Makefile,把bin后面改成ringQueue,src后面改成RingQueue.cc。
》我们正式来谈谈环形队列,有没有人知道,环形队列具体是怎么去实现的呢,在数据结构层面上我们应该怎么去定义他呢,我们其实用的就是数组去实现的。数组呢是固定大小,包括有人说,链表行不行,链表也实行的,只不过我们就用数组,因为它的大小是固定的,最好用数组,为什么呢,因为只要是链式结构它的在内存里相对位置连续,连续的话呢,我给们说一个思路,你们看看能不能理解,如果你用链表做环形队列,它的数据没有集中放在一起,没有放在一起,那么作为一个环状结构呢照样是可以跑的,没有问题。但是呢,因为数据没有放在一起,将来系统可能给我们做很多的缓存,包括从你的COU到内存,甚至呢,未来你的数据有保存到磁盘的动作和把磁盘数据缓存到内存里,所以你没有把数据放在一起是不利于缓冲区命中能理解吧。而我们,如果把数据放到数组里,其中这个数组,它呢集中在一起,被我们的系统加载运行的时候,它的缓存命中率高,所以我们在选择什么数据结构来实现环形队列的时候,我们是比较推荐数组的。另外同学们也能感受到,有时候你想得到一个问题的答案,可能这个问题的答案并不在一个某一个技术本身。比如你想回答C语言上什么是地址空间,可能其实对应答案并不在语言本身,比如你要回答为什么要使用数据结构,它的可能并不在数据结构语言本身,它可能在系统和网络层面上,所以这一点我们要注意。
》下面我们用数组,相当于我们数组每一个都是一个对应的元素。然后呢,这个数组里面有很多的元素,我们对应的有下标,假设它有10个元素,然后下标呢,我们比如说是i,当i++到我们的越界的时 候,做模运算就能回到最开始,其中呢就相当于我们环状的跑起来了。今天呢重点不是谈这个,我想给大家谈谈,如果我们是环形的数组,我们将来呢,是想一个线程往我们对应的数组里面放,一个线程从对应的数组里面拿。也就是,生产现场:push,消费线程:pop,我今天想问大家一个问题,在环形的数组当中,我们可不可以让生产者和消费者访问的是数组的不同的位置呢,比如说我们的生产者从环形的某一个位置生产,顺时针旋转,曾经它也生产了一批数据,消费者从环形的某个位置消费,那么可不可以让生产者正在生产的时候,让我们的消费者同时也能从他的尾部去拿数据呢?相当于什么呢,就相当于我们生产者向环形队列里面生产任务,它访问的是其中一个位置,然后消费线程呢,是访问的另一个位置, 所以可不可以正在进行拷贝的时候,同时也正在进行拿出,可以吗?答案是:可以的。这就叫做,如果让我们的多个线程,以两个线程为例,让它们两个访问,同学们今天很明显生产线程要访问这个数组,消费线程也要访问这个数组 ,那么这个数组我们就称之为临界资源。只要我们让它们两个访问临界资源的不同区域,此时我们就可以让他们两个同时生产和消费。注意,我们前面讲过了,我们在讲阻塞队列的时候,并发不一定是在临界区当中,但是在环形队列里面并发是不是体现在临界区当中了。让我们在生产数据时,同时我们让消费者也同时来消费。所以对我们来讲,它们两个随时随地都可以进行在我们临界资源内,在不同区域并发吗?换句话说,只要在这个数组里面,让他们访问不同的区域,那么它们两个一定在放和拿这件事情上一定是可以并发起来的。但是有没有可能他们两个会访问同一个位置呀!你说它们两个是访问不同位置, 那它们两个可不可能访问同一个位置呢?答案是:有可能!
》当时我们以前讲单进程访问环形队列的结构的时候,一定听说过,这个环形队列呢,可以判空也可以判满,那么同学们,你们当时是怎么判空判满的?假设一开始整个数组是为空的,所以生产者和消费者它们两个,环状嘛,肯定是有头有尾,头尾肯定一开始是指向同一个位置,然后呢,我们是先生产,然后下一个,生产下一个…在默认情况下,整个数组没有元素的时候,两个是指向同一个位置,当我们生产一个,下一个,生产一个,下一个,我们可以看到默认情况下,空和满都是指向同一个位置。这个当时航哥应该是讲过的。那么其中你怎么进行对我们的数组进行判空和判满呢?那么,要么我们就预留一个,预留一个我们的空位置不用,空的时候是指向同一个位置,满的话就是当前位置的下一个位置是我们的结尾,此时这个时候就是满课;还有一种方案,就是计数器法,有数据我就++,数据少了我就–,你想判空判满直接看计数器就行了。这是曾经给大家讲的,我们今天完全就不需要考虑环形队列为满为空,为什么呢?因为有信号量帮我们考虑。我们根本就不关心再多线程下为满为空,因为信号量可以为我们保证,信号量不就是计数器吗,为满为空信号量会给我们保证。
》那么下一个问题,我想问一下,不用你们所说预留空位置的情况, 为满为空是不是都会指向同一个位置?我的问题就是,当我们不考虑用空位置的方式是空还是满的时候,那么我们要访问同一个位置,会在什么时候发生?也就是说呢,我们两个执行流想访问所谓的同一个位置的时候,那么什么时候会发生访问同一个位置呢。同学们,你们不是讲过,你今天刚说过了,生产和消费只要访问的是不同位置,那么它们两个就可以并发起来了,但是现在的问题是,那么先不考虑它们两个并发,我就问你它们两个什么时候会访问同一个位置。那么给大家讲一个小故事,今天呢可以把环形想象成是一个圆桌,这里的每一个空格上都是我们的一个盘子,然后我们两个线程就绕着这个桌子玩一场追逐游戏,就是,我呢在你前面跑,追逐你。此时我们两个玩一个追逐游戏,最开始的时候,我们在同一个位置,那么我们呢规定好,我现在往桌子里面的盘子放苹果,同学们当我们往n个盘子里面放苹果,当前盘子放的时候你不能来拿,只有当我放成功的时候,我要么不放,没有苹果的时候你必须得等;如果我放了苹果,放成功了你才能来拿。所以最开始的时候么,我在这里放苹果放了一圈,然后你嗯就跟着我的后面。假设最开始,我们的起始位置是一样的,所以我们两个指向同一个位置,此时桌子上全是空盘子,没有放苹果。这个时候呢,我们两个谁先有运行呢?答案是,你必须让我生产线程先运行。所以呢,我跑完放一个苹果,不好意思,你太慢了,我把苹果都放完了,你来不急处理,导致我呢,放苹果,放苹果…最后我放了苹果又回到这里,此时呢我们两个指向了同一个位置,此时我这个桌子上的苹果已经放满了,还能不能放呢?答案是:不能了。 这个时候就得让你消费线程赶紧跑,那么你此时消费线程在赶紧跑的时候,生产现场很慢,你拿苹果,拿苹果…然后拿完苹果继续走到下一个位置,此时我一直在那里等着,此时你也到这里了,此时我们又指向同一个位置了,此时又回到了最开始的空状态。同学们,所以这里想告诉大家,第一个结论,我们在访问环形队列的时候,我们指向同一个位置的时候,**只有两种情况是指向同一个位置的,只有为满或者为空的时候。**也就是说没我们两个真正指向同一个位置是在为满或者为空的时候。那么其他时候,我们都指向的是不同位置,那么这里我们需要的是什么呢?如果我们两个指向同一个位置时,我们需要的时候互斥!光互斥行不行呢,你可以看到,当我们满的时候,不能让生产者再生产了,而是让消费者消费,当为空的时候,不能让消费者消费了,而是让生产者去生产,二而且我们两人不能同时访问这个位置,所以我们在互斥的前提条件下要具有执行的一定的顺序性!什么时候让我们的生产者跑,什么时候让我们的消费者跑, 所以这叫做什么呢?还要包括同步!所以,互斥和同步本质体现在我们两个指向在同一个位置的时候。第二个,当我们指向不同的位置,我们是可以实现并发的。

下面的目标是,将我们的环形队列的模型搞定,第二个呢,要把我们的多线程做一下适度的处理,让同学们能够编写出线程池任务,这个呢也要搞定;第三个,剩下的任务呢,要给大家花时间后续的工作全部搞定。
》下面呢,我们要谈的任务呢主要是环形队列的问题。那么环形队列呢,它最大的好处我们会发现,它实际上会把我们的整个空间呢切割,切割之后呢,我们整个环形队列呢,不需要整体使用,而是使用它的局部,这样的话呢,在一个生产和一个消费的同时呢,我们会发现一个问题,它们两个呢,只有在我们的环形队列为空或者为满的时候会指向同一个位置,而除此之外呢它们两个是不会出现指向同一个位置的情况的,这样我就可以在生产的同时再去进行我们的消费。当然,正如我们前面所讲,这里所说的再生产的同时呢,也同时在消费,其实指的就是把数据和任务放到这个数组里面,或者呢可以同时从数组当中把数据再拿走,同学们,你懂的,实际上在这个问题,它的效率上呢确实有,但是排除我们自己是以拷贝的方式去把对象放到数组当中,除此之外,一般我们这种传递型的任务一般不大,但是呢,给大家说一下,它在效率上呢,其实是有的,但是我们一定要抓住主要矛盾。是在获取数据获取我们对应的任务和处理任务的时候也要花时间,所以它虽然也是一种方案,但是呢它毕竟不像我们阻塞队列一样,会彻底的把我们的所有线程拦截,串型的时候只去处理一个我们的任务。只不过呢,我们现在要对他做一个深度理解的话,把它交代清楚。
》首先我们理解清楚了,当我们两个位置指向同一个位置的时候,只有为空和为满的时候,那么其他的时候呢都是指向不同的位置,那么下面呢还要沟通一个小问题,如果我们两个指向的是同一个位置,那么请问我们此时应该让谁先运行呢?那么我们是不是要考虑清楚是在满的情况下指向同一个位置呢,还是空的情况下指向同一个位置呢。就好比今天在桌子上放一大堆的苹果,然后呢,当你们追随着我的步调,从我后面拿着苹果,假如你拿的特别慢,我放的特别快,此时转一圈了你还没拿完,那么我能不能超过你呢,此时我和你是属于满的状态,我能不能超过你?答案是:不能。同样的,你拿的特别快,我放的特别慢,那么其中你在拿的时候能不能超过我?答案是:也不能。所以我们知道,第一,我们在为空的时候,消费者是不能超过生产者,因为你超过生产者了,接着下面的数据根本就没有,如果你继续向下读取都是读到的废弃数据。第二,我们在为满的时候,生产者不能把消费者套一个圈,继续往后写入,不然你会把消费者还没来得及消费的数据覆盖掉了。这是我们后续操作的基本原则。但是还没完,此时呢,不再会说超过你的情况,但是,如果我们两个指向同一个位置,当我们为空为满的时候,为空的时候谁先运行呢? 为空的时候指向同一个位置,我一定要生产者先运行。原因也很好理解,你消费者运行没有意义,当前我给你指向同一个位置为空的时候,当前有没有数据取决于生产者有没有放,所以你一定要让生产者先放了,然后你才能拿。如果为满的时候,我们所对应的生产和消费,我们应该消费者先运行,因为为满的时候,我们让消费者先运行,消费者把数据拿走了,然后才能有你后续的写入。
》那么接下来我们要做的一个工作,你刚刚说的这些原则,谁来保证呢?不是你说让谁先运行就睡运行,不是你想怎么样就怎么样,你得通过编码实现呀,谁来保证你上面说的原则呢?答案就是:信号量来保证!那它怎么来保证呢?所以我们下面来分析一下问题,问一下同学,我们的生产者 和消费者访问都是同一个环形队列,但是我们的信号量的定义是,信号量呢是用来进行描述临界资源数目多少,这叫做我们对应的信号量。那么请问,站在生产者角度,最关心的是什么资源?作为消费者最关心 的又是什么资源呢?你告诉我信号量呢是用来维持你所说的刚刚环形队列在多线程当中正常运转的,那请问,我们所谓的信号量,它当前是如何做到你说的这点的?你呢又告诉我信号量的定义,是用来描述我们特定资源数目的多少的,它是一个计数器,那么请问,生产者最关心的是什么资源,我们消费者又是最关心的什么资源呢?对我们的环形数组而言,我们生产线最关心的资源是空间!就是你特定的数组里面还有咩有位置。而我们消费者最关心的资源是数据!也就是说呢,生产者最关心的是你环形队列的空间有多少,还有多少是没用过的,那么消费者最关心的是数据有多少,还有多少数据是没有取的?当我们明白这一点之后,接下来呢,我想问同学们,所以最开始的时候,我们对应的空间,假设我们的环形队列有n个节点,那么请问生产者最开始的时候,空间的个数是多少呢?你不是计数器,你不是信号量嘛,那最开始的时候环形数组里面有n个空间,那么最开始的时候大家都没有放数据,没有任何的生产活动,所以最开始的时候空间的大小就是N。空间大小是N,所以呢我们生产者要进行生产,它先要进行的就是P操作,来申请我们对应的N资源。所以N本质上是一个我们对应的N一直到0的过程,[N, 0],这是第一;第二,在最开始时我们数据是多少呢?我们数据是0个,在我们消费线程进行P操作时,对不起你此时就会被阻塞,来别着急,所以对我们来讲,你这个数据个数,它的范围的变化一定是从0一直到N的,[0,. N]。那么下面呢,我们可以看到,既然生产者和消费者最关心的资源,我们把它判定成不一样的资源,一个关心空间,一个关心数据。那么同学们,我今天要定义信号量,sem_t roomSem,sem_t dataSem;我们roomSem一开始是不是 = N,而我们的dataSem = 0。所以此时呢,我们有两个线程,一个是生产现场,一个是消费线程,它们两个都要先获得资源,然后才能进行生产、消费,对不对。获得一个资源怎么获得呢,其中我们先要进行P操作,而消费线程也要进行P操作。后面两个线程分别也要有V操作。随意生产者要进行生产的话,它要先申请什么呢,你要能生产,你得先跟系统要预定一个空间资源,所以你得先申请一个空间资源。作为消费者来讲,你要进行消费,那么其中你想消费之前,首先得保证有数据你才能消费呀,也就是说,你的先申请数据资源, 然后根据数据资源你才能访问到你想访问的数据。如果数据都没有,你拿什么访问,对不对。如果生产线程先申请了空间资源,然后生产了,那么生产的本质是在干什么呢》?生产的本质是放数据。当我把数据放完了,当我要V的时候,数据还在吗?我刚刚不是将数据放在那里了吗,数据放那里的时候,我最后操作完了,当前我将数据放过去了,我操作完数据还在吗?数据还在,因为数据本身是占用空间的,作为生产者将数据放上去了,或者不这么问,这么问容易误解。我将数据放进去了,当我操作完的时候,那是不是数据还占据着空间,那么当我要V的时候,空间是少了一个,但是数据多了一个!所以当我们V的时候你申请的是空间资源,你像系统归还的时候是dataSem。换句话说,就相当于什么呢,就相当于,你将数据放进去了,放进去之后,当你准备V的时候,其中这个数据一定还没有被别人使用,因为它是属于在你自己的临界区里面,稍后我们还有其他的小细节。总之呢,当我V的时候数据还是占据着空间,这个空间是少了,但是数据多了一个,所以我V数据。当我们消费者,消费的本质是什么呢?消费的本质是拿数据。那么我消费完之后,本来申请P,我们要预定我们的数据资源,预定数据资源之后呢,我拿走了是应该的,因为数据资源本来就是我的,拿走之后呢,数据确实还是少的,但是空间又漏了出来 ,空间漏出来了,我只要把数据取走,是不是相当于我们就多了一个roomSem,所以我们通过各个线程申请自己所关心的资源,各自释放对方所关心的资源,那么此时它们两个可以互相的步调协同了。
》我们再来分析一下游戏规则是什么呢? 游戏规则是,消费者不能超过生产者,所以当我们最开始的时候,我们的信号量,空间信号量和数据信号量,一个为N,一个为0。假设消费者线程先来了,消费者来了,此时他要申请data资源,当他要申请data资源的时候,对不起data资源默认为0,没有这个资源,所以线程就被挂起了。线程被挂起就不能继续往后执行了,然后呢我们生产者即便他来的晚,它也照样能够申请到我们的空间资源,因为空间一抓一大把,所以空间资源一旦申请到了,它把数据生产完,然后最后在进行V的时候,把我们data资源进行释放,data信号量计数器V就是 ++ , ++ 之后呢,数据资源 变多了,曾经挂起的线程里吗就反应到,此时有数据了,此时它这个时候才能进来消费。所以最开始为空的时候,我们一定能保证哪个线程先运行时能够保证的 ,一定是生产线程先运行。最后总结一下,最开始的时候,我们的信号量,空间计数器时为N,随便它。数据计数器为0,那么此时,消费线程哪怕你来的早,申请你要 – ,对不起,此时已经是0,没有信号量给你申请,此时你这个线程就会被挂起。挂起之后呢,只有生产者把数据生产出来了,在V给你,让数据计数器变1的时候,你才会被唤醒,换而言之,在生产和消费同时到来的时候,或者哪怕消费者先到来,此时对不起你也不能执行,会将你拦住,然后等生产线程生产完毕了你才能消费,这是当数据为空的时候,一定会保证生产者先生产。假设消费者不消费 ,生产者不断的生产 ,不断的减少自己的空间信号量,增加数据信号量,因为不断的在放数据,意味着空间越来越少,数据越来越多,最后把空间信号 – 到0了,数据信号量 ++ 到了N,此时生产者还想再生产,在申请roomSem的时候,对不起,已经为0了,你不能申请生产了。此时生产为满的情况,生产者再来生产,对不起空间资源没有你的份了,没有了的话,你就得挂起等待,此时呢,我们消费者就可以安安心心的进行消费了,当消费者消费一个释放一个空间信号量,生产线程才能去生产。所以经过它,我们又得出第二个结论,如果为满的情况,无论是生产者还是消费者,谁先启动起来,无论是什么情况,最终一定是让消费者先进入我们的临界区访问我们的临界资源, 那么此时这就通过我们为满的情况,空间为0,数据为N,只有消费者能够进入我们的临界区进行消费,所以为满的时候,不管谁先跑,最终都是先让消费线程进去消费,消费出来了一个才会让你生产线程去运行。所以为空和为满的时候,我们已经通过信号量来保证了他们两个步调一致。
》除了你会说的为空和为满的时候,只要他们不为空和不为满的情况,也就是说不是它们两个指向同一个位置的情况,那么此时他们两个可以并发的生产和消费,这就是信号量给我们带来的好处。以上就是如何使用信号量在我们环形队列中对我们资源进行保护,以及同步两个信号量的过程。 记住了信号量是一个挂起等待机制的计数器,当我们申请信号量能P操作申请成功,那直接就返回了,申请不成功,那么当前线程就会在该信号量下被挂起等待,说白了就是阻塞住,这叫做信号量。有一个细节没有和我说,什么细节呢?这个细节就叫做,你怎么保证不同的线程访问的是不同的临界资源中不同的区域???你上面说的挺玄乎的,说一了一大堆P、V,我们两个线程申请自己所关心的资源,释放对方的资源的,这样我们就可以保证 我们是没有问题的。我下面一个问题就是,你凭什么给我这个保证? 你怎么保证,前提条件是你怎么保证生产和消费访问的是我们环形队列当中的不同区域呢?这个是要由我们程序员编码保证的。那么程序猿编码怎么保证呢,下面我们来编写一下。上面我们所讲的所有原理、细节,从上面的概念在我们分析上,再到我们得到的基本原则,再到我们后面如何保证基本原则我们全部说完,还有一个小问题没有解释,我们待会儿在代码里解释出来。
》我们首先在RingQueue.hpp里面,定义一个RingQueue类型,class RingQUeue,作为一个我们对应的环形队列,我们想让它里面放什么呢?首先你得告诉我你这个环得多大,用什么数据结构来保存这个环呢。我们不是说数组吗,那就是vector。紧接着就是,作为编写内部成员来讲,首先要明白的点,vector ring_queue_;我们接下来呢既然是一个环形队列,环形队列我们在初始构造的时候,即构造函数,RingQueue(int cap = g_cap): ring_queue_(cap){};所以对我们来讲呢,我们就预先的创建出来我们的环形队列,然后我们来个全局变量g_cap = 10;刚开始容量不要过大,方便我们测试。可是光有一个环还不行,第二个呢你还得告诉我信号量,生产者和消费者分别关心的东西是不一样的,所以我们信号量类型,sem_t ,信号量这里我们得有几个信号量呢,得有两个,一个叫做我们对应的roomSem,它呢叫做空间信号量,表示的是空间,谁关心呢,生产者producror。下一个,dataSem,它呢衡量的是数据计数器,消费者consumer关心。作为一个我们对应的消费者,最关心的是有没有数据。再下来你是一个环形数组,你怎么保证我们的生产者和消费者在访问数组里,用的是不一样的资源呢? 因为是数组,所以数组是通过下标来标定,来一个uint32_t pIndex;uint32_t cIndex;pIndex表示的是当前生产者写入的位置,cIndex表示的是当前消费者读取的位置,此时我们的生产者和消费者读写不同的位置,我们就可以用下标来区分。所以我们现在所需要的一个成员呢已经搞定了。下面我们再来进行析构函数,那么其中我们使用的信号量资源,在构造函数的时候就得对其初始化,在不用之后,你得对它进行释放,所以信号量我们得如何申请和释放呢。sem_init()第一个参数就是你要初始化的信号量,第二个参数是否共享,我们设置为0就行,第三个参数,我们得自己想想了,同样的,是pthread库给我们提供的,即sem_init(&roomSem, 0, );sem_destroy()只要将你要销毁的信号量传进来就可以了, 即sem_destroy(&roomSem);所以我们就构造和销毁了对应的计数器。它们对应的PV操作可都是原子的。下面问同学们一个问题,请问初始化信号量sem_init()函数的第三个参数填几呢?roomSem被谁使用呀,空间信号量是一定要被我们生产者使用,dataSem是要被我们消费者使用,在最开始的时候,我们的整个环形队列是为空的,环形队列是为空的,此时我们最开始的空间为多少个,是不是就是我们的ringqueue.size()对应的一个大小,环形队列有多大,所以我们的roomSem的第三个参数就是我的计数器就有多大,为N,sem_init(&roomSem, 0, N)。那么我们的dataSem一开始环形队列里面没有数据,第三个参数是不是就是0呀,sem_init(&dataSem, 0, 0)。我们的基本骨架已经出来了。
》下面我们再来,我们就不做封装了,我们前面阻塞队列封装就够了,若想封装的话自己封装也行。作为一个环形队列,你要提供的方法叫做,void Push(const T& in)由我们的生产者来调,T Pop() 消费者来拿。所以呢如何去生产呢, 你要生产,你先申请信号量,然后你才能去生产,所以要用信号量等待函数,sem_wait(),让信号量值 – ,这就是wait操作,所以要想生产就得先,sem_wait(&roomSem),当申请了roomSem资源了,我不是要生产吗?那就生产呗,那怎么生产呢,生产的过程就是我们对应的ringQueue_[],当前你是生产到什么位置了,是不是pIndex位置了,所以ringQueue_[pIndex] = in;这就叫做生产的过程,很简单对不对,后续你也可以通过new/malloc来构建我们的对象,也是可以的,因为地址空间是共享的嘛,不一定非得把整个对象后任务拷贝过来,你也可以传指针对不对。下面呢,pIndex索引进行 ++ ,因为这个pIndex只有一个生产者会访问,所以不需要将对应的 ++,放入到我们的临界区当中。我们pIndex++ ,怎么办呢,往后生产,往后生产…总会出现越界的情况吧,那么在pIndex %= ringQueue_.size();pIndex++我们叫写入位置后移动,pIndex %= ringQueue_.size()我们叫做判断更新。判断更新来保证我们的环形队列更新。你生产完了之后怎么办呢,你要进行V操作,那怎么办呢,调用sem_post()函数,其中就可以让我们的信号量直接进行递增,那么我们要对哪个信号量递增,我把空间拿到了,申请到了roomSem,生产时,数据已经放里面了,第一,空间大小还没变,第二,我们的格子被占用了,格子被占用了一个怎么办呢,也就是数据多了一个,那是不是得对dataSem进行++呢。所以sem_post(&dataSem);这样我们就完成了P、V操作,那么这就是完成了一次生产的过程。
》那么接下来呢,我们就要进行消费,消费怎么消费呢,你要消费,那么前提条件是不是得先申请什么资源呀,你要消费,你的前提条件是什么,是不是你必须申请数据资源,你得有数据才能消费对不对,所以sem_wait(&dataSem);再下来呢,消费怎么消费呢,我们是不是可以定一个临时变量T tmp = ringQueue_[cIndex_];你消费的位置是不是cIndex呀。我们将cIndex_的位置拿出来之后,要不要对该位置进行清空呢?不需要,我们后面呢是一个追一个,所以对我们来讲,最终跑起来了, 跑起来之后呢,我们把数据拿出来之后,该位置的数据后面会被自动覆盖,我们就不需要清理了,这是一种惰性释放的方式。最后把数据拿走了,那谁就漏出来了呀,那不就是空间露出来了吗? 则要进行我们的V操作,sem_post(&roomSem);空间漏出来了,那是不是相当于空间多了一个,那么此时我们的消费就完成。完成之后怎么办呢,是不是更新一下位置,cIndex_++,然后cIndex %= ringQueue_.size();就这么一点点代码,我们就完成了一个基本的环形队列。上面我们分析的如此复杂呢,就用信号量的引入就很快的解决了。
》接下来呢,我们要做的工作就是去用它。最开始消费者线程和生产者线程同时来,一定是生产者线程运行,消费者线程被挂起,因为当前没有数据,只能是生产者先生产,生产完毕之后,它sem_post()把我们的dataSem增加了,那么消费者就是识别到了有数据就能来消费了。那能不能正常运行呢?我们就在RingQueuetest.cc里面测试一下。我们首先是种一个随机种子,srand((unsigned long)time(nullptr))^ getpid());然后我们接下来就是创建线程,pthread_t c,p;然后就是pthread_create(&c, nullptr, consummer, ),第四个参数暂时先不填,pthread_create(&d, nullptr, productor, );这两个线程我们怎么让他们看到同一份资源呢?我们得首先在其前面,定义出循环队列,RingQueue rq;所以我们就有了环形队列,然后我们刚刚到第四个函数参数没填,就可以填我们的环形队列了,即pthread_create(&c, nullptr, consummer, &rq);pthread_create(&d, nullptr, productor, &rq);此时它们两个就能看到同一个环形队列了。然后我们就要些void
consummer(void
args) 和 void
productor(void
args)了,首先就是在里面,RingQueue* rqp = static_cast<RingQueue *> (args);作为生产者,while循环可以重复的进行我们的生产了 ,怎么生产呢,int data = rand() % 10;rpq->push(data);对我们来讲,消费者呢,int result = rqp->pop();我们main()函数里面创建了线程就得回收新线程所以不要忘了调用pthread_join()函数。然后我们进行编译,就可以看到他们在疯狂的进行生产和消费了。别着急,我们既然要验证它的一个互斥与同步,同步的策略怎么做呢?我们可以让我们的生产者生产的慢一点,那我们就在生产回调函数调用sleep(1);在环形队列中生产者生产的慢,你这个消费者有数据就拿,没数据就等,我们只把生产者变慢了,那么消费者也必须得跟着我们的节奏放慢。所以此时我们加了sleep(1)之后呢再编译并运行,可以看到生产一个消费一个,生产一个消费一个…那么如果此时让我们的消费线程变慢呢?那么我们的生产者一定是先进行我们的生产,生产的时候反正没人跟我抢,一瞬间就会将我们的环形队列打满,打满在生产的时候还能再生产嘛,答案是:不能生产了,不能生产了你就得等消费者消费,它消费一个你才能生产一个。我们在编译并运行,正如我们所想。
》这个是不是很简单呀,这个确实是比较简单。如果你想封装任务类型,我们正好也适用的模版类型,也可以吧我们的环形队列搞成任务。我们下面要做的下一个工作,给大家思考一下,我们是单执行流,单生产者和消费者,如果我们要写成多生产者和消费者呢??请问代码怎么改?我们刚刚写的是一个生产者和一个消费者,我们首先要回答的就是,如果我们刚刚写的是多生产者和多消费者会不会有问题呢?比如说,很多生产者同时进来了。会不会有问题呢?有多个生产者和消费者同时过来生产和消费了,请问当前的代码你该怎么改写呢?同学们,我们生产和消费模型讲过吗,我们曾经讲的生产和消费在这份代码里3种关系是哪3种关系呢?叫做生产者和生产者之间是互斥的,消费者和消费者之间是互斥的,生产和消费者之间是互斥且同步的。而我们信号这里压根没有加锁,但是有没有互斥的成分在里面呢,实际上是有的,就是当环形队列为空或为满的时候,他们去竞争信号量条件不满足就会被挂起,这设计的其实很好。同学们,我现在的问题,你像前面的代码,是条件变量的关系,它本来就是加锁的,多生产和多消费都没关系,那现在这份代码你怎么保证多生产和多消费也没问题呢?很简单,当前每一个执行流不管是生产还是消费,进来之前都得先申请信号量资源,申请完信号量之后你才能进行后续的访问对不对,下面的一个问题是什么呢,下面一个问题就是如果是多生产呢?如果是多生产者,因为今天的信号量初始值可能是10,如果有5个线程同时来的话,每一个线程进行P操作,P操作虽然是原子的,我们很清楚是原子的,而一旦我们P操作是原子的话,其中对我们来讲呢,一定会有大量的线程进到临界区,大量的线程进入到临界区时候,当前你把数据由Index来决定放到某个位置,你用Index我也用,所以就有可能两个线程呢就访问到了同一个变量Index,同学们,别忘了,如果是多线程这里的pIndex也是临界资源!同理cIndex。换句话说呢,如果你这份代码改成多线程,其中索引这个变量其实,就是下标嘛,其实他也是临界资源,那么此时我们应该怎么进行处理呢?那么我们肯定要保证加锁。说再多,今天我们环形队列里,生产和消费同时进入临界区,没有问题,我们两个只要不访问同一个位置就可以,但是如果是多生产和多消费,那你就得维护生产者和生产者之间,消费者和消费者之间的互斥关系。那么我们RingQueue类里面还得再加上pthread_mutex_t pmutex_ 和 pthread_mutex_t cmutex_两把锁,我们一旦上锁了,不管你怎么办,你们内部先竞争出来一个胜利者,然后再访问我的环形队列,你们自己内部先争,我不管,我反正只知道我最多只允许你们一个线程进入我的临界资源当中写入,我也不管,我只关心你一个线程进入我的临界资源当中读取,但是你们的内部先进行竞争 。所以我们在构造函数内部还得加上pthread _mutex_init()函数对我们的锁进行初始化。析构函数那必然就要加上pthread_mutex_destroy()函数 ,这就把两把锁给初始化和销毁写好了。我们先来看生产者,你要加锁,你在哪里加锁呢? 不管三七二十一我们现在最上面和最下面先把pthread_mutex_lock()加锁函数写出来,pthread_mutex_unlock()解锁函数写出来。在我们多生产者和多消费者情况下,生产者加自己的锁,消费者加自己的锁,它们两个互相不影响,但是我们知道,只要进入到加锁的线程呢,生产者们你们互相抢吧,既然是竞争关系,我们两个之间没有同步关系,我们两个暂时不考虑同步关系,现在就是这几个线程你去抢吧,你们谁抢到了谁再去申请信号量资源,然后再进行生产,这样可以吗?首先,只要我们加锁了,我拿到了锁了,就一定证明什么呢,一定怎么,进入当前临界区的线程只有一个,一定只有一个,那么此时我们作为一个生产者,它将来在我们的环形队列当中进行我们的消费,此时呢你先申请锁,申请到了之后,然后你再进来申请信号量,然后你再生产,生产完之后,你再进行解锁等后续操作,这样没有问题。想象一种极端情况,如果消费者不消费呢,消费者不消费你在不断生产,不断生产最后有一个线程来了, 它申请了一把锁,申请一把锁之后怎么样了呢,它申请了信号量,不好意思,此时你应该被挂起了,挂起来之后呢,其他生产线程也来了,他们竞争锁,在锁那里等着,挂起之后,当消费者最终再进行消费,拿数据时,那么它会把你那个线程唤醒,因为信号量有了,唤醒之后去生产,生产完之后再解锁,这个没有问题,我们可以多创建几个线程试试,我们创建了6个线程,它们都会去进行生产和消费。我们编译并运行,可以看到生产了5个数据,此时来了一批消费线程,它们也消费了一批数据,我们确实也看到了,生产了一批数据也就消费了一剖数据。消费的时候这些线程,可以由不同的线程来进行消费。但是呢,实际上我们这样去写是没多大问题的,因为你加锁之后就是串型化,你线程去竞争,谁和谁竞争,是生产者和生产者去竞争,消费者和消费者去竞争,你们通过锁,互相你们先争,谁抢到了锁,谁才有资格申请信号量,然后往环形队列里放数据,放的时候可以被线程切换,但是不影响,我们正常把数据放进去,然后Index正常往后移动。很好,这一切呢都没问题。
》但是呢,这里有一个什么东西呢,把锁加在最上面的话,那么也就决定了,我们对应的信号量无法被多次的申请。换句话说呢,如果此时一个线程竞争锁成功了,当我这个线程访问完毕之后,下一个线程进来的时候,它只能进行把自己在锁上挂起,被唤醒之后呢才能去申请信号量,我信号量本来是是10,你信号量既然是资源的预定机制,我可不可以让所有的线程先申请信号量,申请完信号量你不要害怕,你信号量申请成功了,此时一定有你的资源,然后呢,当你最后把操作做完的时候呢,此时你10个线程,你们10个线程先申请信号量,把你们的手牌拿上,保证接下来一定有资源给你去访问,为什么呢?因为信号量表示的临界资源的数目。你现在呢申请了信号量,给你了,那你就一定有资源,至于把你的资源排到哪儿呢,你们10个在内部在竞争,谁先竞争到锁,谁就先去放数据,然后释放,解锁,下一个线程再进来的时候,它继续同样的操作。其中在这个过程中不影响其他线程,只要每一个线程拿到了信号量,它一定能够在数组当中有他对应的生产空间,而我们吧sem_wait()放在pthread_mutx_lock()前面,就可以在竞争锁期间,所有线程等待的时候,在锁这里等,每一个人都拿着信号量去等,而不是像我们一开说的,你们先申请锁,当被唤醒时,我还得去竞争我们的信号量,没必要!先把手牌给你们,拿着,想进来的话你们一个个进来,就跟同学们去学校里考试,进考场之前,先给考场同学发一个准考证,只要你拿着准考证,你不要害怕,因为你知道有你的座位,然后进入到教室之后,门太窄了。一个个进,你怕什么,进去之后呢,所有人都会按照自己的索引正常放数据。所以将信号量申请放在前面写法,可以一次批量的把该有的资源全拿到,然后安安稳稳的串型化往里面写入,这就叫做我们对应的多生产和多消费的写法。
》所以就相当于呢,我们实际在操作的时候,你们几个线程,你们先申请信号量,一人拿一个,没有了的话,我就不让它拿了,我信号量呢就拦住不让后面的线程拿了, 只要拿到了信号量你就大大方方的进去竞争锁,当一个线程拿到锁生产完了,在锁等待的其他线程立马就能拿到锁,为什么呢,因为信号量他已经抢到了,所以此时再申请到锁,放数据等后续一系列操作,这就是我们对应的多生产者和多消费者对应的一种写法。所以呢,这个代码当我们再去跑的时候,信号量你先申请嘛,拿到信号量,你们几个再去竞争锁,该让谁访问了,相当于什么呢,相当于此处呢,我们对应的锁是保护我们对应的写入位置Index变量的,每一个线程把数据写入了,然后退出,退出的线程你想直接占有锁,你想得美,你先去申请信号量,申请到了你才有资格去竞争锁。那么我曾经在你访问临界区的时候,有4、5个线程都来了,都来了怎么办呢,一人拿一个手牌,比如说我们信号量一共有10个信号量。 然后最后你有10个线程过来拿,可以,我直接把信号量分完,分完之后你们几个去竞争锁,串型竞争的话,如果生产完一个了,你再进行post,你再去申请我们对应的信号量,对不起,没了。为什么呢,因为你用了一个,放了一个数据,你再想申请呢,对不起,此时没有信号了,此时9个线程拿着对应的信号量正在锁上等待着呢,你现在还想要,因为你刚刚花了一个,现在呢,消费者还没消费,你此时在这里等着吧,等的时候呢,剩下的线程去竞争,只有当剩下的线程竞争锁完成了,最后把数据放了,释放了锁,甚至最后都sem_post()了,第一个线程还在那里等,对不起还是没信号了,消费者还是没消费,生产满之后,消费者来消费,消费者把对应的V操作指向完之后,你们才有生产线程从信号量里申请成功,然后拿锁去放数据等一些列操作。所以,这样还可以在一定程度上提高效率的,这叫做多生产者和多消费者模型,也叫做我们的3 2 1原则。生产者和生产者之间互斥,消费者和消费者之间互斥,生产者和消费者之间是互斥关系且同步,它们两个同步如何体现呢,当资源没有的时候,需要等待,当对方把我需要的资源释放的时候,我才继续向后执行。
》如上就是我们的多生产者和多消费者模型,搞定。这叫做理论和实践相结合,最后再问一个问题,其中我们把数据或任务,我们为什么要创建多生产者和多消费者呢,我闲着没事干,为什么呢?你在环形队列里依旧是串型执行的,为什么要有多生产和多消费呢,你不是提过并发吗,生产和生产之间是互斥的呀,消费者和消费者之间也是互斥的呀,你们虽然有信号量同步,但你们还是互斥的呀,一次最多只允许一个生产者和消费者,虽然生产和消费可以同时进行,但是生产和生产,消费和消费确实互斥的 ,你所谓的并发体现在哪里呢?为什么,我要闲着没事干,要创建多生产和多消费呢?这个答案我们前面的多生产和多消费模型说过了,**不要只关心把我们对应的数据或者任务,从RingQueue中放拿的过程,你要知道,获取我们对应的数据或者任务,以及处理获取的任务,也是需要花时间的。**如果我今天处理的任务特别耗时,需要去等,所以我可以多起一点线程,可以让我们同时等待多种资源,这样的话呢,任何一个资源就绪的概率就增加了。所以我们采用多生产和多消费呢,其实根本就不是生产者和消费者本身要求的,而是你要结合你的场景去确定你平时是不是有很多的数据处理任务,或者获取的任务,那么它能够需要通过创建多个线程去解决的,那么这个时候你再用多生产和多消费。实际上呢,我们单生产和单消费在这种场景当中其实已经够了。
》我们关于信号量,生产和消费者模型,互斥与同步全部讲完。下面进入下一个概念,叫做线程池。

RingQueue.hpp
pragma once
 2	
 3	#include <iostream>
 4	#include <vector>  
 5	#include <string>
 6	#include <semaphore.h>
 7	
 8	using namespace std;
 9	
 10	const int gCap = 10;
 11	
 12	template <class T>
 13	class RingQueue
 14	{
 15	public:
 16	    RingQueue(int cap = gCap): ringqueue_(cap), pIndex_(0), cIndex_(0)
 17	    {
 18	        // 生产
 19	        sem_init(&roomSem_, 0, ringqueue_.size());
 20	        // 消费
 21	        sem_init(&dataSem_, 0, 0);
 22	
 23	        pthread_mutex_init(&pmutex_ ,nullptr);
 24	        pthread_mutex_init(&cmutex_ ,nullptr);
 25	    }
 26	    // 生产
 27	    void push(const T &in)
 28	    {
 			pthread_mutex_lock(&pmutex_);
 29	        sem_wait(&roomSem_); //无法被多次的申请
 30	        pthread_mutex_lock(&pmutex_);
 31	
 32	        ringqueue_[pIndex_] = in; //生产的过程
 33	        pIndex_++;   // 写入位置后移
 34	        pIndex_ %= ringqueue_.size(); // 更新下标,保证环形特征
 35	
 36	        pthread_mutex_unlock(&pmutex_);
 37	        sem_post(&dataSem_);
 38	    }
 39	    // 消费
 40	    T pop()
 41	    {
 			pthread_mutex_lock(&cmutex_);
 42	        sem_wait(&dataSem_);
 43	        pthread_mutex_lock(&cmutex_);
 44	
 45	        T temp = ringqueue_[cIndex_];
 46	        cIndex_++;
 47	        cIndex_ %= ringqueue_.size();// 更新下标,保证环形特征
 48	
 49	        pthread_mutex_unlock(&cmutex_);
 50	        sem_post(&roomSem_);
 51	
 52	        return temp;
 53	    }
 54	    ~RingQueue()
 55	    {
 56	        sem_destroy(&roomSem_);
 57	        sem_destroy(&dataSem_);
 58	
 59	        pthread_mutex_destroy(&pmutex_);
 60	        pthread_mutex_destroy(&cmutex_);
 61	    }
 62	private:
 63	    vector<T> ringqueue_; // 唤醒队列
 64	    sem_t roomSem_;       // 衡量空间计数器,productor
 65	    sem_t dataSem_;       // 衡量数据计数器,consumer
 66	    uint32_t pIndex_;     // 当前生产者写入的位置, 如果是多线程,pIndex_也是临界资源
 67	    uint32_t cIndex_;     // 当前消费者读取的位置,如果是多线程,cIndex_也是临界资源
 68	
 69	    pthread_mutex_t pmutex_;
 70	    pthread_mutex_t cmutex_;
 71	};
RingQueueTest.cc
#include "RingQueue.hpp"
 2	#include <ctime>
 3	#include <unistd.h>
 4	
 5	// 我们是单生产者,单消费者
 6	// 多生产者,多消费者??代码怎么改?
 7	// 为什么呢???多生产者,多消费者?
 8	// 不要只关心把数据或者任务,从ringqueue 放拿的过程,获取数据或者任务,处理数据或者任务,也是需要花时间的!
 9	
 10	void *productor(void *args)
 11	{
 12	    RingQueue<int> *rqp = static_cast<RingQueue<int> *>(args);
 13	    while(true)
 14	    {
 15	        int data = rand()%10;
 16	        rqp->push(data);
 17	        cout << "pthread[" << pthread_self() << "]" << " 生产了一个数据: " << data << endl;
 18	        sleep(1);
 19	    }
 20	}
 21	
 22	void *consumer(void *args)
 23	{
 24	    RingQueue<int> *rqp = static_cast<RingQueue<int> *>(args);
 25	    while(true)
 26	    {
 27	        //sleep(10);
 28	        int data = rqp->pop();
 29	        cout << "pthread[" << pthread_self() << "]" << " 消费了一个数据: " << data << endl;
 30	    }
 31	}
 32	
 33	int main()
 34	{
 35	    srand((unsigned long)time(nullptr)^getpid());
 36	
 37	    RingQueue<int> rq;
 38	
 39	    pthread_t c1,c2,c3, p1,p2,p3;
 40	    pthread_create(&p1, nullptr, productor, &rq);
 41	    pthread_create(&p2, nullptr, productor, &rq);
 42	    pthread_create(&p3, nullptr, productor, &rq);
 43	    pthread_create(&c1, nullptr, consumer, &rq);
 44	    pthread_create(&c2, nullptr, consumer, &rq);
 45	    pthread_create(&c3, nullptr, consumer, &rq);
 46	
 47	
 48	    pthread_join(c1, nullptr);
 49	    pthread_join(c2, nullptr);
 50	    pthread_join(c3, nullptr);
 51	    pthread_join(p1, nullptr);
 52	    pthread_join(p2, nullptr);
 53	    pthread_join(p3, nullptr);
 54	
 55	    return 0;
 56	}

线程池(代码是被改成单例模式之后的了,之前的要想看可以去回顾视频)

我们以前是接触过池化的概念的,我们当时是在讲进程池的时候说过,我们当时在讲管道的时候用父进程创建一大批子进程,然后通过管道进行连接,父进程可以负载均衡式的给每一个子进程进行派发任务,我们采用的是随机的内容。首先给大家说一下,线程池这个东西包括我们多线程在后半部分它其实都是属于网编编程的内容。其实我们就这么说,我们学到的历史上学到的所有的知识全部都应该被划分到网络编程。因为,确实人家是系统编程,网络编程的分支很小,但是系统和网络编程是不分的,要记住了,我们学的所有代码都会被应用到我们的网络当中的。所以对我们来讲呢,我们接下来要谈论的一个概念叫做线程池,它有什么用呢,通常一样的,当我们网络当中有任务到来的时候,我们可能要让我们的多线程去帮我们处理。你所说的任务到来处理任务指的是什么意思呢?我们的意思指的是,如果我们现在的任务,现在的任务是随机设置的,未来的任务呢可能是某个网络当中的请求,网络当中的请求呢,有对应的需要你读取的Io,还有需要你做计算的,所以呢,我们可以通过池化的技术来提高我们的效率。理论上呢,这些池化的技术应该在我们同学把网络讲完我们再去看看,可能会更好理解一些,多线程嘛,既然在这,上下文也比较一致,那我们顺便把它一讲。
》那么线程池呢,和进程池一样,它是一种池化技术,也是一种线程使用或者进程使用的模式。大家都知道,我们线程过多的时候,会带来我们对应的调度的开销,继而影响我们缓存局部整体的性能。但是呢线程池它维护着多个线程,那么它呢可以等着别人来给他派发任务,那么记住了,当任务到来时你才创建线程,那么此时的成本会非常高,而如果提前先把各种池化的东西准备好,当任务来的时候,我直接指派给一个线程,那么此时我们就可以直接使用我们的叫做,可以理解成使用我们的线程池的方案来对我们的资源进行合理的使用。我给大家举一个例子,就好比你去一家餐厅吃饭,然后呢,如果对服务员说我想吃一个西红柿炒鸡蛋,那么厨师就拿着西红柿和鸡蛋就去抄了,这是一种比较快的方式,还有一种方式就是,行,你要吃西红柿炒鸡蛋,我去市场给你买西红柿和鸡蛋,你先等一等,等我买回来了再给你炒,炒完了你再吃。很显然,第一种方案,人家现成就有已经准备好的西红柿和鸡蛋,你现在让我池西红柿炒鸡蛋,还得再去市场去买,让我等你得等很长时间。所以呢,我们可以理解成,无论是进程池还是线程池本质上都是一种对于执行流一种预先分配。当有任务的时候直接指定,而不需要再去创建线程或进程来帮你处理任务了。我们现不要着急,我们能感受到它的好处,我们虽然能感受到,但是他的应用场景我们确实不知道。当我们能够感受到,其实我们是可以通过某种方式,去控制我们对应的叫做线程的,就跟我们当年控制进程一样。下面嗯,我们怎么去控制一批线程呢。
》下面给大家说一下,我们要写的线程池呢,想给大家写一个固定数量的线程池,也就是说这个线程池我不想给大家做过多的封装,封装太多了反而不是很好。我们就用固定的数量和线程来帮助同学们写一个固定的线程池。我们的线程池最终就是暴露一个让我们投递任务的接口,你把任务给我就不管了,后面所有的事情我帮你做完,这就是线程池。该怎么写呢?
》不废话首先创建一个threadpool.hpp;我们现在线程池有了,你后面还会有各种任务对不对,那么你这戏任务到来的时候,我们这里叫做threadPoolTest.cc,我们将我们前面的Makefile继续用到此处,只不过把src后面改为ThreadPoolTest.cc,形成的可执行程序呢,则就把bin后面改为ThreadPool。下面呢我先给大家说一下,不要担心,因为一会儿在写的时候,是会发现,我们想写一个什么东西呢,我们想写一个线程池。那么线程池这里呢对应的是一个任务队列,这个任务队列呢,我们不用阻塞式任务队列,有任务你就给我拿。我们使用线程池的人呢,它嫩可以通过接口,向任务队列里放任务,而我们呢,有若干个线程,这个若干个线程呢彼此竞争式的从任务队列当中拿任务。哪个线程拿到了任务,就由哪个线程去处理,我们先写一个简单的。再问一下,当我们来一个任务的时候,我们有一个任务队列,来一个任务你就往任务队列里放,后面有一大批线程,有任务了你就去拿。同学们,这是一个什么东西?有任务就往我们任务队列里面去放,没有任务呢你此时你们所有的线程都在我们对应的某些条件变量下去等,有任务的时候你就去处理,这是一种什么东西,说白了,它就是生产和消费模型。后面呢,你会发现,在我们服务器设计里面呢,有相当多的百分之八十以上的情况都是生产和消费模型,它用的是最多的。
》我们首先在ThreadPool.hpp里面,定义出ThreadPool类,class ThreadPool{}想想,它里面应该有什么成员呢?我们的线程池呢,将来想让它处理各种各样的任务,所以我们在定义ThreadPool类的时候,也要使用模版template。 我们该有的封装内容,该有的还是要有的。你要有线程池你得先有一个 int threadNum_;线程个数,你要线程池,你得先告诉我线程的个数是多少,这是其一;其二呢就是,我们对应的线程池呢,你想去创建他呢,课件里呢把所有的线程保存起来,比如你用queue将ID保存起来,方便你后续曲join(),但是我们今天把所有的线程进行分离。我们需要queue taskQueue_,我们需要有一个任务队列,来让我们的线程池当中的线程能够跑起来。其三呢,有任务的时候你得通知我们的线程,没任务的时候,我的线程就要去休眠,所以我们必定要包含pthread_mutex_t mutex_这是让我们的线程互斥的去进行获取我们任务队列当中的任务。当我们任务队列里面面没有任务的时候,得让我们线程去条件变量下去等,有任务的时候我们再去唤醒某一个线程,此时就得再有一个pthread_cond_t cond_条件变量。大家也知道,我们的条件变量本来也是排队的,所以唤醒某个线程的时候,它的线程没有被唤醒之前,线程在排队,有任务了,我们就可以唤醒线程将任务交给他,所以我们天然就可以使用条件变量的特习来实现我们多线程负载均衡式,按照轮询的方式让每个线程去执行我们对应的任务。
》我们下面第一个工作就是,初始化我们的线程池,所以来写构造函数,当前的线程池呢,你得首先传入线程个数,我们来一个 int gThreadNum = 5;所以ThreadPool(int thradNum = gTheadNum),创建好之后呢,创建线程池之前,我们首先要构造的时候可以来一个叫做,把所有的内部成员初始化好。初始化的时候。taskqueue不用管默认就会调用他自己的初始化函数,锁的初始化要调用pthrad_mutex_init()函数,条件变量要调用pthread_cond_init()函数。析构函数呢,就是调用锁的销毁函数和条件变量的销毁函数,即pthrad_mutex_destroy 和 pthread_cond_destroy()函数。所以此时呢,我们的一个线程池的初始和析构我们就都有了,然后怎么办呢,然后我们要让我们的线程池做一个最重要的工作,第一个,我们的线程池得留一个start接口,也就是说呢,你想让你的线程池启动,你得有一个start函数;第二,线程池对我们来讲,对外暴露的呢一个start接口我们让它启动,再下来呢,我们要一个start让所有的线程跑起来,第二个,同学们,想想,线程池是不有一个任务队列,有了任务队列的话,我们是不是必须得有人能够从队列里拿任务。有人能从队列放任务,所以得有一个push()接口,我们也得有人来拿,所以还得有一个pop()接口,这个T pop()接口可以不暴露给外面。也就是说呢,给你外部使用我们线程池的人,就给你一个start 和 push接口,你要用我的线程池,先构建一个对象,然后start起来,起来之后呢,你再向我的线程池push任务就可以了,你有什么任务,就往我里面放什么任务,我来帮你做计算。
》我们下面先来写我们的start,那么要start的话,线程的个数,你一旦要start,就意味着你当前的线程其实是已经跑起来了。所以线程池呢可以加一个字段,bool类型,bool isStart;表示当前的线程池是否已经启动。下面呢,如果你要start,第一件事情你就是要assert(),同学们,作为任何一个线程池能不能被重复启动呢?答案是:不能。不能被重复启动,此时你要start。前提条件是什么,是你这里的,我们的isStart成员变量在构造函数的时候,先初始化为false,也就是你线程池一开始是没有启动的,防止有人呢重复启动线程池,我们就得有assert(!isStart),当我准备启动的时候,将我们的isStart设置为true,即isStart = true;也就是说,我启动之后呢,调用了start()接口,我将其改成true,下次再启动的话,就会直接拦截你,不让你执行下面的代码。当然你也可以用if判断,然后return也行,下面呢,我们要创建对应的线程,那怎么去创建呢,我们可以来一个,把所有的线程信息呢保存起来,保存起来,那怎么保存呢?或者我们先暂时不保存线程信息。我们for循环,一共有多少个线程呢,就是你传入的或者默认的个数,即for(int i = 0; i < threadNum; I++)当然呢,有没有可能别的用户给我们传进来个数,所以线程个数可以再初始化列表阶段给初始化一下,其中呢,还可以加上aseert对我们的线程个数做一下判断,其中线程个数必须得是 >0 的条件,我才会后续给你操作。接下来要做的工作呢就是创建线程,创建线程,则先定义出线程,pthread_t tmp;我们先不保存信息,然后pthread_create(&tmp, nullptr, Routine; nullptr )线程要做什么任务呢,叫做Routine,线程要做一些正常的历程操作,就是它这个线程应该做什么工作,名字就叫做Routine,最后一个参数我们也设置为nullptr,暂时设置为nullptr,一会儿我们这里要讲一下。下面呢,一旦循环创建成功,那么我们的主线程就不断的在for循环,直到我们i值不满足条件再退出,结束创建完成讲isStart改为true,然后继续往后执行了。我们所对应的其他线程在做什么呢?其他的线程是不是就跑过去执行我们的对应的回调函数Routine函数了!所以我们需要有一个void* Routine(void* args){}。现在我们的线程池已经有start接口可以被调用,可以先测试一下。我们可以用一下智能指针,即unique_ptr<ThreadPool> tp (nwe ThreadPool()); 然后就是,tp->start();让我们的线程池跑起来,然后我们的主线程什么也不做,while循环内部加一个sleep(1);我们make的时候会发现问题,有个什么问题呢,我们以前写的线程是在哪里写的呢,是在类外面谢的,而你今天写的接口呢投壶Routine()是一个类内成员,那么我们称之为成员函数,那么成员函数有默认参数this指针。所以既然会有一个隐藏的this指针,你的Routine(void args)看起来只有一个参数,其实是有两个。所以你想要在你的类内把线程搞起来,你只能定义成static,定义成static后我们都知道,static成员函数无法访问类内成员,所以呢你无法访问类内成员,这个static void* Routine(void* args)属于类内而不属于对象,所以我们无法访问类内成员的话,你只能进行编写各种接口来让其跑起来,而更关键的是,我怎么知道我这个线程要跑起来了,线程跑起来将来是要从对应的任务队列里拿任务,那么这个线程如何获取到你的任务队列进而从里面拿任务呢,所以在start接口里面还有一个细节,在我们的pthread_create(&temp, nullptr, ThreadRoutine, this),最后一个参数传上this指针。也就是说呢,我们创建一个线程,在我们的线程ThreadRoutine函数里给它传进this指针,这样的话呢,我就可以在类内ThreadRoutine()函数内通过对应的ThreadPool* p = static_cast<ThreadPool* >(args)构造一个对象。所以此时我们就拿到了所谓的线程池对象。所以你想要启动线程的时候你只能这么干!因为有this指针,在C++里面用原生的线程呢,是需要你有一点点的概念在里面的。此时我们再make就编译通过了。
》下面要做的工作就是我们的线程池已经启动了,我们需要做的工作呢,我们一家启动了这么多的线程,然后这些线程呢一定会有线程向我们的任务队列当中放任务。要放任务,第一件事情,就是要从任务队列里面拿任务,比如说一个人要放任务,你必须得在push接口里进行锁住队列lockQueue()当你放完之后,你还得解锁ulockQueue(),那怎么生产任务呢,很简单,你不是有taskQueue_,push(in);解锁之后怎么办呢,那我们是不是要悬着一个线程ChooseThreadForHandler(),就相当于呢,我们锁住队列,向我们的任务队列放一个我们的任务,然后我们放完之后解锁,再选择一个线程去帮我们执行,你将ChooseThreadForHandler()放在解锁上面也行。那么很显然我们有一堆的接口要封装一下,void LockQueue(){pthread_mute_lock(&mutex_)};void UnLockQueue(){pthread_mutex_unlock(&mutex_)};至此就完成了一个加锁和解锁的任务,还有一个ChooseThreadForHandler()选择一个线程去处理我们后面再说。每个线程他一定是要先申请锁,static是属于类的,无法使用类内的其他成员函数,或者说因为它没有this指针无法使用其他成员函数或者成员变量,所以你要访问的话,只能以接口或者this指针来进行访问,所以在ThreadRoutine()函数内tp->LockQueue()和tp->UnLockQueue(),在他们之间的代码就是从任务队列里面拿任务了,你说拿就拿嘛,凭什么,这是第一;第二,我们把线程启动起来的时候不打算等这个线程,那么我们就将该线程分离,则调用pthread_detach(pthread_self());在加锁和解锁之间就是帮我们处理任务的代码,要想帮我们处理任务,你首先得检测是否有任务,没有任务的话你这个线程是不是应该等呀,怎么办呢,既然要判断是否有任务,那么我们就加一个函数bool HaveTask(){return !taskQueue_.empty()};则while(!tp->HaveTask()) 没有任务的话,我就想让当前的线程等待,那在哪里等呢,我们就要写一个WaitForTask()函数,它必须在线程池内部来等待特定的任务,那么其中走出while(!HaveTask())的循环就是有任务了,有任务了,那怎么办,那是不是就是拿任务了,则定义一个变量T task = tp->pop();这个任务就被拿到了线程的上下文中。以前的任务呢是在你的任务队列里,拿下来之后,我们当人就可以t.run()了,但是你记住了你还是在加锁期间,不要在临界区里面处理任务,而是规定我们将来所有的任务都必须有一个run()方法,也就是你怎么处理这个任务呢,这个任务自带处理方法,我就调用对应的run方法救能够把任务处理完,处理完之后,这个线程继续参与。接下来呢就又有一批接口需要写一下.
》void WaitForTask(),void ChoiceThreadHandler(),等待某一个任务的到来呢,没有人任务所有的线程都会去条件变量下去等,所以你要等,我此时的条件变量也给你准备好了,你直接用就行了, 怎么办呢?所以在WaiForTask()里面调用pthread_cond_wait()函数,记住你要等待,在哪里等呢,是不是&
cond_,其二,在等待任务的时,它检查又没有任务,有任务就拿,拿了就去处理,但是自己在去等的时候是在加锁和解锁之间的,用的是线程池内部的锁,所以你要进行等待,你必须得把锁释放掉,所以pthread_cond_wait()第二个参数就是&mutex_,即pthread_cond_wait(&cond_, &mutex_)这样呢,线程就在该条件变量下等待且会自动释放锁。另外呢,如果我们对应的向我们任务队列里面放任务的人呢,放好之后,会选择一个线程ChoiceThreadForHandler()去执行,选择一个线程去执行,不就是在该函数里面调用pthread_cond_signal()唤醒在&cond_该条件变量下等的线程,让他去执行吗?因为条件变量自带队列,所以呢,所有的线程没有任务所有线程都会在条件变量下等,说白了就是排队,排队等时候此时,我们唤醒一个执行一个,唤醒一个执行一个…这就是我们对应的线性轮询方案的负载均衡。所以接下来我们有了这一批接口之后呢,我们再来一个pop接口,就是拿任务。这个pop呢是线程池内部的线程去使用的,我们这个线程池不需要在pop()的时候呢,再去花时间给它加锁,不需要,因为它本来就在我们的临界区里面pop()的,所以我们这个接口同样设为private私有,那么这个T pop()怎么写呢,拿任务,首先我们走到pop()这里,我们已经假设了这个接口的使用场景就是,任务队列里面一定是有任务的,要不然线程是不会执行pop()接口的。所以我们不用在pop()里面判断任务情况,那要在里面写些什么呢?我们直接T temp = taskQueue_.front(),所以我们拿出来了一个任务,然后就是taskQueue_.pop(),return temp,那么我们线程就拿到了任务,拿到任务就去执行run方法去处理任务,然后处理完,就继续while循环,判断又没有任务,有的话就去那没有就在条件变量下等待。
》我们main()函数里面线程池已经定义出一个对象了,也已经start启动了,那我们主线程呢,就充当派发任务的角色,while循环,可是我们现在有任务吗?不好意思,没有。但我们不担心,因为我们上面就写过一份代码就是专门的任务结构体拿过来用。那么接下来就可以做一件事情,就是进行我们的任务派发,派发的话你得有随机数,srand((unsigned long)time(nullptr) ^ pthtread_self()),在while循环内,int one = rand() % 50; int tow = rand() % 10; char op = operators(rand() % 5),当然肯定是要定义出const string operators = “±/%”,搞定之后呢,我们是不是得有任务,那么就用Task类来构造一个任务对象,Task t(one, tow ,op);任务构建好了,我们是不是得派发呀,那不就是往线程池里面push()吗,所以tp->push(t);此时我们就完成了向指定的线程池派发任务了。派发后,线程池内部 线程就去抢单了,线程池一start就开始执行自己回调函数了。我们的任务里面呢,本来就有run方法。我们想再写一个Log.hpp写一下我们日志。线程处理这个任务,怎么处理呢,刚好我们的Task类里面呢,有get()方法,所以可以在ThreadRoutine()方法内定义int one,two,op,传给Task对象t,t.get(&one,&two,&op);
》这样我们就写好了我们的线程池了。如上就是我们多线程部分的代码相关的所有内容。还有一个代码呢,留下来给同学们处理,叫做,如何对一个线程进行封装。我将封装的思路说一下:在C++11里面是有thread头文件的,如果我自己想封装,那我该怎么封装呢,是不是你要有一个类,叫做thread类,thread类里面就应该包含线程相关的信息。这里唯一封装的就是我们的线程需要有一个回调函数,大家可以下来,我要求的是支持Lambda这么一个设计。

ThreadPool.hpp
#pragma once
 2	
 3	#include <iostream>
 4	#include <cassert>
 5	#include <queue>
 6	#include <memory>
 7	#include <cstdlib>
 8	#include <pthread.h>
 9	#include <unistd.h>
 10	#include <sys/prctl.h>
 11	#include "Log.hpp"
 12	#include "Lock.hpp"
 13	
 14	using namespace std;
 15	
 16	int gThreadNum = 5;
 17	
 18	template <class T>
 19	class ThreadPool
 20	{
 21	private:
 22	    ThreadPool(int threadNum = gThreadNum) : threadNum_(threadNum), isStart_(false)
 23	    {
 24	        assert(threadNum_ > 0);
 25	        pthread_mutex_init(&mutex_, nullptr);
 26	        pthread_cond_init(&cond_, nullptr);
 27	    }
 28	    ThreadPool(const ThreadPool<T> &) = delete;
 29	    void operator=(const ThreadPool<T>&) = delete;
 30	
 31	public:
 32	    static ThreadPool<T> *getInstance()
 33	    {
 34	        static Mutex mutex;
 35	        if (nullptr == instance) //仅仅是过滤重复的判断
 36	        {
 37	            LockGuard lockguard(&mutex); //进入代码块,加锁。退出代码块,自动解锁
 38	            if (nullptr == instance)
 39	            {
 40	                instance = new ThreadPool<T>();
 41	            }
 42	        }
 43	
 44	        return instance;
 45	    }
 46	    //类内成员, 成员函数,都有默认参数this
 47	    static void *threadRoutine(void *args)
 48	    {
 49	        pthread_detach(pthread_self());
 50	        ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);
 51	        prctl(PR_SET_NAME, "follower");
 52	        while (1)
 53	        {
 54	            tp->lockQueue();
 55	            while (!tp->haveTask())
 56	            {
 57	                tp->waitForTask();
 58	            }
 59	            //这个任务就被拿到了线程的上下文中
 60	            T t = tp->pop();
 61	            tp->unlockQueue();
 62	
 63	            // for debug
 64	            int one, two;
 65	            char oper;
 66	            t.get(&one, &two, &oper);
 67	            //规定,所有的任务都必须有一个run方法
 68	            Log() << "新线程完成计算任务: " << one << oper << two << "=" << t.run() << "\n";
 69	        }
 70	    }
 71	    void start()
 72	    {
 73	        assert(!isStart_);
 74	        for (int i = 0; i < threadNum_; i++)
 75	        {
 76	            pthread_t temp;
 77	            pthread_create(&temp, nullptr, threadRoutine, this);
 78	        }
 79	        isStart_ = true;
 80	    }
 81	    void push(const T &in)
 82	    {
 83	        lockQueue();
 84	        taskQueue_.push(in);
 85	        choiceThreadForHandler();
 86	        unlockQueue();
 87	    }
 88	    ~ThreadPool()
 89	    {
 90	        pthread_mutex_destroy(&mutex_);
 91	        pthread_cond_destroy(&cond_);
 92	    }
 93	
 94	private:
 95	    void lockQueue() { pthread_mutex_lock(&mutex_); }
 96	    void unlockQueue() { pthread_mutex_unlock(&mutex_); }
 97	    bool haveTask() { return !taskQueue_.empty(); }
 98	    void waitForTask() { pthread_cond_wait(&cond_, &mutex_); }
 99	    void choiceThreadForHandler() { pthread_cond_signal(&cond_); }
 100	    T pop()
 101	    {
 102	        T temp = taskQueue_.front();
 103	        taskQueue_.pop();
 104	        return temp;
 105	    }
 106	
 107	private:
 108	    bool isStart_;
 109	    int threadNum_;
 110	    queue<T> taskQueue_;
 111	    pthread_mutex_t mutex_;
 112	    pthread_cond_t cond_;
 113	
 114	    static ThreadPool<T> *instance;
 115	    // const static int a = 100;
 116	};
 117	
 118	template <class T>
 119	ThreadPool<T> *ThreadPool<T>::instance = nullptr;
ThreadPoolTest.cc
#include "ThreadPool.hpp"
 2	#include "Task.hpp"
 3	#include <ctime>
 4	#include <thread>
 5	
 6	
 7	// 如何对一个线程进行封装, 线程需要一个回调函数,支持lambda
 8	// class tread{
 9	// };
 10	
 11	int main()
 12	{
 13	    prctl(PR_SET_NAME, "master");
 14	
 15	    const string operators = "+/*/%";
 16	    // unique_ptr<ThreadPool<Task> > tp(new ThreadPool<Task>());
 17	    unique_ptr<ThreadPool<Task> > tp(ThreadPool<Task>::getInstance());
 18	    tp->start();
 19	
 20	    srand((unsigned long)time(nullptr) ^ getpid() ^ pthread_self());
 21	    // 派发任务的线程
 22	    while(true)
 23	    {
 24	        int one = rand()%50;
 25	        int two = rand()%10;
 26	        char oper = operators[rand()%operators.size()];
 27	        Log() << "主线程派发计算任务: " << one << oper << two << "=?" << "\n";
 28	        Task t(one, two, oper);
 29	        tp->push(t);
 30	        sleep(1);
 31	    }
 32	}
Task.hpp:
pragma once
 2	
 3	#include <iostream>
 4	#include <string>
 5	
 6	class Task
 7	{
 8	public:
 9	    Task() : elemOne_(0), elemTwo_(0), operator_('0')
 10	    {
 11	    }
 12	    Task(int one, int two, char op) : elemOne_(one), elemTwo_(two), operator_(op)
 13	    {
 14	    }
 15	    int operator() ()
 16	    {
 17	        return run();
 18	    }
 19	    int run()
 20	    {
 21	        int result = 0;
 22	        switch (operator_)
 23	        {
 24	        case '+':
 25	            result = elemOne_ + elemTwo_;
 26	            break;
 27	        case '-':
 28	            result = elemOne_ - elemTwo_;
 29	            break;
 30	        case '*':
 31	            result = elemOne_ * elemTwo_;
 32	            break;
 33	        case '/':
 34	        {
 35	            if (elemTwo_ == 0)
 36	            {
 37	                std::cout << "div zero, abort" << std::endl;
 38	                result = -1;
 39	            }
 40	            else
 41	            {
 42	                result = elemOne_ / elemTwo_;
 43	            }
 44	        }
 45	
 46	        break;
 47	        case '%':
 48	        {
 49	            if (elemTwo_ == 0)
 50	            {
 51	                std::cout << "mod zero, abort" << std::endl;
 52	                result = -1;
 53	            }
 54	            else
 55	            {
 56	                result = elemOne_ % elemTwo_;
 57	            }
 58	        }
 59	        break;
 60	        default:
 61	            std::cout << "非法操作: " << operator_ << std::endl;
 62	            break;
 63	        }
 64	        return result;
 65	    }
 66	    int get(int *e1, int *e2, char *op)
 67	    {
 68	        *e1 = elemOne_;
 69	        *e2 = elemTwo_;
 70	        *op = operator_;
 71	    }
 72	private:
 73	    int elemOne_;
 74	    int elemTwo_;
 75	    char operator_;
 76	};
Log.hpp
#pragma once
 2	
 3	#include <iostream>
 4	#include <ctime>
 5	#include <pthread.h>
 6	
 7	std::ostream &Log()
 8	{
 9	    std::cout << "Fot Debug |" << " timestamp: " << (uint64_t)time(nullptr) << " | " << " Thread[" << pthread_self() << "] | ";
 10	    return std::cout;
 11	}

将线程池代码改成单例模式

》我们目前呢已经将线程当中的所有内容都讲完了,操作与理论相关的都操作完了,但是呢,还有一些话还得给大家明确一下,有一个概念叫做线程安全,它对应的一个单例模式。单例模式就不讲了,在C++里面是讲过的,所以我们讲讲特点,说完之后就把刚刚的线程池就该成单例的,也就是我们试着把线程池改成单例模式。剩下的就是STL是否是安全的,STL的智能指针是不是安全的,还有一些其他常见的锁说说。其实这些锁都是用我们linux线程相关的锁概念提出来的,另外你在网上搜索的锁概念都不是我们C++都锁,搜索出来的是别人java等其他语言封装等锁,也会给大家提下一。读者写者问题有些接口给大家说一下,接口在使用的时候很简单,结果不太明显。我们一般创建对象的时候,有懒汉式的创建也有饿汉式的创建,这两种创建方式其实就是对于你的单例模式什么时候进行加载的问题。你可以定义全局的,加载的时候直接给我们创建好了,另外,你也可以定义通过指针的方式来做。还有就是饿汉和懒汉实现单例其中它里面定义我们称作的单例对象,定义好之后就可以GetInstance()获取对象,然后我们懒汉的方式呢就是可以直接定义指针。我们重点在于把刚刚写的线程池设计成单例模式。
》 我们采用我们指针的方案,也就是懒汉模式。我们定义一个static ThreadPool* intstace,这里我们就定义了一个静态的线程池指针,然后把所谓的构造函数变为私有private,但把析构函数暴露给外部。我们可以加一个构造函数方法,ThreadPool(const ThreadPool&) = delete;void operator = () = delete;下面的问题是什么呢,我要获取线程单例,那么我们static成员变量的初始化必须得在类外,template ThreadPool* ThreadPool::instance = nullptr;此时呢,就相当于我们构建了一个单例,单例当中呢,可以有一个指针,类型是ThreadPool。另外给大家说一下,C++11现在类内也是支持const static int a = 100;下面呢,我们自己要进行单例呢,需要写一个ThreadPool* GetInstace()我们要获得它,要获得它的话呢,因为他这个方法本身是要获得我们所对应的,我们称作的指针,对象嘛,这是其一;第二个你要记住了,像这种函数他不能用对象来访问,它必须得以类的方式来访问,所以该函数也得是static类型,即static ThreadPool* GetInstance();我们接下来该怎么获取呢,先进行if判断,如果当前静态方法属于类,它可以直接访问类内的其他静态成员成员,叫做我们定义的Instace指针,我们拿过来判断,即if(nullptr == Instace),如果等于了说明当前获取单例的时候,单例并不存在,不存在怎么办呢,此时我们直接 instace = new ThreadPool();线程的个数你也可以通过GetInstace函数进行传给他,但我们就不写了,就用我们默认的全局5个吧,我们new出来之后就紧接着return instace;但是我们说过,这是有所谓的线程安全问题,所以我们要写出来较为安全的话呢,可以实现见加锁和解锁 ,我们前面也写过Lock.hpp,我们就用我们对锁进行封装后的锁,它是RAII风格的, Lock.hpp里面呢,有一把锁,我们可以定义一个静态的Mutex对象,static Mutex mutex;这个mutex它其中在我们类里面呢, 带下划线的是类内成员变量,不带的是我们定义的。我们写好的Lock.hpp中的Mutex类,在构造函数的时候就给我们写好了锁的初始化,析构函数也帮我们销毁了锁。但是static是全局的,构造好了之后,我们直接用它,相信大家并不陌生,static定义出来的对象是全局的,所以我们锁就有了,有了之后怎么办呢,我们可以继续加判断if(nullptr == instace),这个判断呢,仅仅是过滤重复的判断,它肯定有线程安全问题,我们要的是保证下面是安全的,那么我们接着在里面定义个LockGuard lockguard(&mutex),定义一个临时的LockGuard对象 ,该对象呢,会自动调用其类的方法进行加锁和解锁,所以每次请求的时候,这把锁是确定的。所以,进入代码进行加锁,退出代码块的话,会自动给我们解锁,RAII风格对代码块的利用风格刚刚好。单例有了该怎么用呢,此时我们的构造函数没了,所以你unique_ptr<ThreadPool > tp(new ThreadPool())就用不了。我们这里要获得对象的话,我们unique_ptr<ThreadPool > tp(ThreadPool::GetInstance())有一个GetInstace()函数,这样就获取到了单例,获取到单例之后,接下里啊ThreadPoolTest.cc下面代码不变。我们来测试一下,看能不能行。
》我们可以用prctl()函数来对我们的线程进行操作,我们可以在主线程调用prctl(PR_SER_NAME,“master”),在新线程调用的回调函数ThreadRoutine()里面可以调用prctl(PR_SER_NAME,“follower”),我们把线程的名字设置好了之后呢,再来进行运行。我们线程的名字就分别改成了master和follower了,所以你自己想要对线程做命名上面的更改都可以。
》再下来呢,回答问题,我们用的STL智能指针等接口是不是线程安全的呢?答案是:不是,我们现在用的STL标准库或者是你们未来用的其他准标准库,里面的很多功能在设计的时候,它本身就不是为了去保证线程的安全去运行的,也不是说他故意这么些,而是说为了将我们的性能挖掘到极致,那么但是一旦涉及到锁,此时就会引起线程安全的问题或者性能受到极大影响,所以对于容器呢我们一定要记住清楚,容器本身大部分操作都是不安全的,你想嘛你的容器无论拉出来哪一个,拉出来之后呢,里面或多或少都会有内存操作,不管怎么样,里面一旦用了malloc等那一定是全局的,所以呢,它基本上很难做到安全,所以一般呢我们使用容器的话 ,我们加锁的话,就需要你自己去加锁了。所以STL本身不保证安全,需要程序员你自己去保证安全,这也就是为什么我们一开始写代码的时候,我们虽有的接口都是需要我们自己去保护接口的。我们也知道unique_ptr智能指针不能被拷贝,只对在当前的代码块里面有效,而我们用shared_ptr是可以进行传参的,那么它能是采用引用计数的方式对资源的进行管理。可以想想,只要是引用计数肯定是全局的,都可以访问,所以对我们来讲,这个是线程安全的嘛,目前呢我们的标准库是考虑到了这个问题,主要是这个问题其实并不难理解,所以它使用了基于原子操作的C AS保证它的操作,什么是CAS就是,比较并交换,其实呢它是一种硬件级别给我们保证的原子性操作,说白了也是以一种硬件级别的方式来保证我们原子性操作来对我们的引用计数器做修改的,总之呢,我们可以理解成shared_ptr是线程安全的。

ThreadPool.hpp
#pragma once
 2	
 3	#include <iostream>
 4	#include <cassert>
 5	#include <queue>
 6	#include <memory>
 7	#include <cstdlib>
 8	#include <pthread.h>
 9	#include <unistd.h>
 10	#include <sys/prctl.h>
 11	#include "Log.hpp"
 12	#include "Lock.hpp"
 13	
 14	using namespace std;
 15	
 16	int gThreadNum = 5;
 17	
 18	template <class T>
 19	class ThreadPool
 20	{
 21	private:
 22	    ThreadPool(int threadNum = gThreadNum) : threadNum_(threadNum), isStart_(false)
 23	    {
 24	        assert(threadNum_ > 0);
 25	        pthread_mutex_init(&mutex_, nullptr);
 26	        pthread_cond_init(&cond_, nullptr);
 27	    }
 28	    ThreadPool(const ThreadPool<T> &) = delete;
 29	    void operator=(const ThreadPool<T>&) = delete;
 30	
 31	public:
 32	    static ThreadPool<T> *getInstance()
 33	    {
 34	        static Mutex mutex;
 35	        if (nullptr == instance) //仅仅是过滤重复的判断
 36	        {
 37	            LockGuard lockguard(&mutex); //进入代码块,加锁。退出代码块,自动解锁
 38	            if (nullptr == instance)
 39	            {
 40	                instance = new ThreadPool<T>();
 41	            }
 42	        }
 43	
 44	        return instance;
 45	    }
 46	    //类内成员, 成员函数,都有默认参数this
 47	    static void *threadRoutine(void *args)
 48	    {
 49	        pthread_detach(pthread_self());
 50	        ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);
 51	        prctl(PR_SET_NAME, "follower");
 52	        while (1)
 53	        {
 54	            tp->lockQueue();
 55	            while (!tp->haveTask())
 56	            {
 57	                tp->waitForTask();
 58	            }
 59	            //这个任务就被拿到了线程的上下文中
 60	            T t = tp->pop();
 61	            tp->unlockQueue();
 62	
 63	            // for debug
 64	            int one, two;
 65	            char oper;
 66	            t.get(&one, &two, &oper);
 67	            //规定,所有的任务都必须有一个run方法
 68	            Log() << "新线程完成计算任务: " << one << oper << two << "=" << t.run() << "\n";
 69	        }
 70	    }
 71	    void start()
 72	    {
 73	        assert(!isStart_);
 74	        for (int i = 0; i < threadNum_; i++)
 75	        {
 76	            pthread_t temp;
 77	            pthread_create(&temp, nullptr, threadRoutine, this);
 78	        }
 79	        isStart_ = true;
 80	    }
 81	    void push(const T &in)
 82	    {
 83	        lockQueue();
 84	        taskQueue_.push(in);
 85	        choiceThreadForHandler();
 86	        unlockQueue();
 87	    }
 88	    ~ThreadPool()
 89	    {
 90	        pthread_mutex_destroy(&mutex_);
 91	        pthread_cond_destroy(&cond_);
 92	    }
 93	
 94	private:
 95	    void lockQueue() { pthread_mutex_lock(&mutex_); }
 96	    void unlockQueue() { pthread_mutex_unlock(&mutex_); }
 97	    bool haveTask() { return !taskQueue_.empty(); }
 98	    void waitForTask() { pthread_cond_wait(&cond_, &mutex_); }
 99	    void choiceThreadForHandler() { pthread_cond_signal(&cond_); }
 100	    T pop()
 101	    {
 102	        T temp = taskQueue_.front();
 103	        taskQueue_.pop();
 104	        return temp;
 105	    }
 106	
 107	private:
 108	    bool isStart_;
 109	    int threadNum_;
 110	    queue<T> taskQueue_;
 111	    pthread_mutex_t mutex_;
 112	    pthread_cond_t cond_;
 113	
 114	    static ThreadPool<T> *instance;
 115	    // const static int a = 100;
 116	};
 117	
 118	template <class T>
 119	ThreadPool<T> *ThreadPool<T>::instance = nullptr;

```cpp
ThreadPoolTest.cc
#include "ThreadPool.hpp"
 2	#include "Task.hpp"
 3	#include <ctime>
 4	#include <thread>
 5	
 6	
 7	// 如何对一个线程进行封装, 线程需要一个回调函数,支持lambda
 8	// class tread{
 9	// };
 10	
 11	int main()
 12	{
 13	    prctl(PR_SET_NAME, "master");
 14	
 15	    const string operators = "+/*/%";
 16	    // unique_ptr<ThreadPool<Task> > tp(new ThreadPool<Task>());
 17	    unique_ptr<ThreadPool<Task> > tp(ThreadPool<Task>::getInstance());
 18	    tp->start();
 19	
 20	    srand((unsigned long)time(nullptr) ^ getpid() ^ pthread_self());
 21	    // 派发任务的线程
 22	    while(true)
 23	    {
 24	        int one = rand()%50;
 25	        int two = rand()%10;
 26	        char oper = operators[rand()%operators.size()];
 27	        Log() << "主线程派发计算任务: " << one << oper << two << "=?" << "\n";
 28	        Task t(one, two, oper);
 29	        tp->push(t);
 30	        sleep(1);
 31	    }
 32	}
Task.hpp:
pragma once
 2	
 3	#include <iostream>
 4	#include <string>
 5	
 6	class Task
 7	{
 8	public:
 9	    Task() : elemOne_(0), elemTwo_(0), operator_('0')
 10	    {
 11	    }
 12	    Task(int one, int two, char op) : elemOne_(one), elemTwo_(two), operator_(op)
 13	    {
 14	    }
 15	    int operator() ()
 16	    {
 17	        return run();
 18	    }
 19	    int run()
 20	    {
 21	        int result = 0;
 22	        switch (operator_)
 23	        {
 24	        case '+':
 25	            result = elemOne_ + elemTwo_;
 26	            break;
 27	        case '-':
 28	            result = elemOne_ - elemTwo_;
 29	            break;
 30	        case '*':
 31	            result = elemOne_ * elemTwo_;
 32	            break;
 33	        case '/':
 34	        {
 35	            if (elemTwo_ == 0)
 36	            {
 37	                std::cout << "div zero, abort" << std::endl;
 38	                result = -1;
 39	            }
 40	            else
 41	            {
 42	                result = elemOne_ / elemTwo_;
 43	            }
 44	        }
 45	
 46	        break;
 47	        case '%':
 48	        {
 49	            if (elemTwo_ == 0)
 50	            {
 51	                std::cout << "mod zero, abort" << std::endl;
 52	                result = -1;
 53	            }
 54	            else
 55	            {
 56	                result = elemOne_ % elemTwo_;
 57	            }
 58	        }
 59	        break;
 60	        default:
 61	            std::cout << "非法操作: " << operator_ << std::endl;
 62	            break;
 63	        }
 64	        return result;
 65	    }
 66	    int get(int *e1, int *e2, char *op)
 67	    {
 68	        *e1 = elemOne_;
 69	        *e2 = elemTwo_;
 70	        *op = operator_;
 71	    }
 72	private:
 73	    int elemOne_;
 74	    int elemTwo_;
 75	    char operator_;
 76	};
Log.hpp
#pragma once
 2	
 3	#include <iostream>
 4	#include <ctime>
 5	#include <pthread.h>
 6	
 7	std::ostream &Log()
 8	{
 9	    std::cout << "Fot Debug |" << " timestamp: " << (uint64_t)time(nullptr) << " | " << " Thread[" << pthread_self() << "] | ";
 10	    return std::cout;
 11	}

✳️其他常见的各种锁
·悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写·锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。(悲观锁就是认为数据被改的频率特别高,然后呢,我在改的时候,我无时无刻不在想的是有人是不是来改我的数据,就是总有刁民想害朕,当我正在访问数据,就怕别人来修改我,那怎么办呢,那么其中呢,我们就可以使用悲观锁,它就是使用数据做访问之前,对数据直接进行加锁,访问完毕之后,在让我们对应的线程释放锁,在我该线程访问期间,其他线程对不起你先被挂起或被阻塞。像我们讲的,不管是二元信号量还是互斥锁全部都是悲观锁。)
·乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。(乐观锁不是系统给你提供的,系统最多给你提供原子性的CAS操作,乐观锁是程序员去实现的,我们学C++一般很少去谈这个乐观锁,但是你将来谈数据库,你学其他的主件的时候,乐观锁程序员给你实现了,我们学到的不再是系统界别的概念了,乐观锁就是别人改我的数据概率特别低,每次我访问数据时候,大概率不会改我数据,所以我对我修改的数据不上锁,不上锁并不代表不安全,所以我们在进行我们数据更新前呢,会先对其他数据有没有修改做判断,一般主流的做法,一般是基于版本号机制,一种是CAS,那么其中呢给大家介绍一下,什么事版本号,给大家说一下,其实并不属于系统范畴,比如说mysql里面实现隔离级别,它引入了微软版本号,它进行了读写分离,同时写的时候形成新版本,这个其实就是一种不加锁的方式,读取呢就直接读取对应的版本号,其实,因为每一次操作都会从形成版本链, 写入形成版本链之后,读取之后拿你特定的版本链就可以了,所以基于这种版本的机制,拿到了实现mysql的隔离性。关于mysql这一堆呢,后面会有讲,现在呢,有一个问题就是,你刚刚说的版本号来控制它安全是怎么做的呢,其实常见的就是,比如说你在应用层面上给你一组数据,一般这组数据里面,我们给他这个数据最后或最前加一个verson1,我们规定了读取就不说了,它不对数据做任何操作。当我们想对他进行修改时,比如说现在有一个线程A,线程A要对数据做修改怎么办呢,我们把这个数据要做修改不是直接上来改,而是拿过来读取出来拷贝一份,或者你可以理解对数据做修改的时候呢,我把数据拿出来,也就是任何操作呢我都先读,其中读呢,不管你是写都不会暂时对数据有任何影响,然后呢他对数据内部做修改,修改完之后呢,我们再规定所有人修改完之后把修改完的覆盖过去,但是在覆盖之前后呢,我会做两件事情,覆盖前呢,我在想去写的时候,我会去判定我曾经保留的版本号和当前版本号是否是一致的,如果这个版本号没有变, 就说明我在修改这个数据之前没有人来改这个数据,所以我这次再进行写入的时候,它就是安全的,它这个数据别人是没有改过,比如说,我自己拿了一个1号版本,当最后去写的时候,版本变为3了,就说明这个数据早就被别人改过了,那我就不能把我数据覆盖进去了,那么我这个数据就直接丢弃了。那么其中对我们来讲这是第一个,版本号的判定。第二个呢,它现在的版本号对我来讲呢,此时我们可以称之为呢,是没有被修改过的,我呢覆盖我的数据,对版本号进行更新,此时呢我就能保证,其他人在我更新期间,它也更新了,那么此时它在判定的时候,它现在版本发生变化了,它也不会对我们的数据做任何修改。其中,当他做写入的过程 ,一般是采用CAS操作,也就是compare and swap,也就是对我们来讲呢是一个原子性的,你可以理解成为,它的操作的呢,把它直接写入的时候,过程是原子的就可以了。不太理解没关系,一般C++不太谈这个,一般是做项目或者公司里面访问一些数据,可能数据库之类你要了解一下,一般搞C++的不考虑,你在网上去搜索,你办搜出来的乐观锁、CAS操作全都是Java的,我们不考虑。 )
·CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
·自旋锁(讲一个小故事,我们之前讲的锁呢,都是挂起等待锁,但是呢,其实还有一种叫做自悬,有一个问题到现如今我们从来没谈过, 假设这是lock,这是unlock(),然后在我们的加锁和解锁之间,这部分区域我们可以称之为临界区,下面问大家,帮助大家去理解,小故事讲完,接口一说,大家自己下来去应用一下。故事是这样的,你明天要考试,然后你跑过去找你的朋友,假设你是李四,朋友是章三,你跑到楼下说,张三你下来吧,我请你吃饭,然后明天要考试了你帮帮我,那么此时,张三说,不好意思,还得40分钟,我现在正在看书 ,你听到张三说还有40分钟怎么办呢,你说,。行吧,这样,如果有人告诉你还要40分钟下来你会怎么做,你会不会说,行,我就在楼下等你,这叫做舔狗,一般正常反应,40分钟是不是,行,一会儿我在楼下找你,这样吧,我现在去我楼下门口,对面有一个网吧,在这个网吧里面呢,我去上会儿网,你先看书,看完了给我打电话我再过去,然后一块儿去吃饭。这呢其实相当于,你让张三去吃饭,他说要40分钟,然后你说行,你去上会儿网吧,然后再一起去,这是第一个;第二个,今天你又找张三了,张三,下来,我请你吃饭,明天要考试了,你下来帮我复习一下,张三说行,你等我一会儿我马上下来 ,作为李四你会不会说,行,你先下来,我上会儿网,你下来了给我打电话。你会不会这样说呢?第二种情况张三说30秒下来,你去说你上会儿网,在路上还得花2分钟到网吧,不会!在这个地方,张三告诉你说一会儿就下来,此时你最合理的做法是什么,你这个时候最合理的做法是,行我在楼下 等你,过了大概几十秒张三还没下来,你又摇电话,好了没?好了好了,马上下来,过了一会儿还是没下来,怎么办呢,你又打电话,到底好了没?这就叫做我们的非阻塞轮询,思路是一样的,通过轮询的方式去检查张三是否就绪。下面呢想问同学们一个问题,刚刚讲的故事,是什么决定了你在楼下是以什么方式去等待的?—等待时间。也就是说呢,如果我今天在我们所对应的叫做楼上告诉我要一个小时下来,我才不在楼下等呢,风吹日晒的,还不直接去网吧上会儿网,你好了给我打电话,那么如果等待时间比较长的话,那我压根不需要在楼下等,应该去上网等路上,上网完回来,这叫做挂起,也就是阻塞前,阻塞中,阻塞后。所以如果我们等待的时间比较长,比较适合于,把自己去上网相当于把自己挂起,然后呢,挂起在路上要花时间,回来也要花时间,如果默认比较长的话,这种锁我们就称之为挂起等待锁,最常见的,互斥锁➕信号量。 那么如果我们等待的时间比较短呢,我在楼下等地啊的时间本来就不长,那我们就轮询检查,检测是否就绪。什么意思呢,意思就是说,我们今天申请锁时,如果在临界区里面呆的时间比较长和在临界区里面呆呆时间比较短就决定了我们的加锁方式。所以,我们一直没有谈过的话题 ,叫做我在临界区里面呆的时间的长短,如果呆的时间比较长,那我们就用挂起等待锁,如果呆呆比较短我们就用自悬锁,其中我们这种长的锁可以称之为我们所谓的互斥锁等,短的呢,称之为自悬锁,pthread_spin_lock();那么其中对我们来讲呢,有人说,那怎么去判定临界区资源长短呢?这个完全是靠实验和我们工作的需要,那么其中我们抢票最适合的逻辑反而是自悬锁,为什么呢,因为抢票是纯内存操作,可以理解吧,内存操作肯定不慢,而我们访问磁盘或者外设,或者你的代码很多,耗费时长比较长的话你就用挂起等待锁。自悬锁好用吗?那么为什么我们不讲呢?其实不并不是我们不讲,而是一写就知道了,自悬锁叫做pthread_spin_Init()函数,就是将mutex换成spin,就代表自悬锁了,自悬锁类型就是pthread_spinlock_t,初始化就是init,销毁就是destroy,然后pthread_spin_lock()就是对我们的自悬锁进行加锁,再呢,就是pthread_spin_unlock()就是解锁。所以你想要用自悬锁,你调用刚刚的接口,一般把所谓的mutex换成spin就可以直接使用了,就这么简单。不过有人会说,不对呀,你这里在做spin做轮询的话,我没有见到你的轮询机制呀,说一下,这个所谓的自悬检测的过程,不是你来做的,关于锁呢,它是系统是库帮我们做的,所以他所谓的自悬是库帮我们自悬的 ,你说我操作系统底层是把我的PCB变成了S状态,在应用层我们看到的就是阻塞,如果我们有一个库,pthread库采用的就是我们自悬的方式进行检测我们的资源,那么我在调用这个接口时我看到的,给我的感受好像是阻塞的。其实很好理解,它的接口是封装好的,它的底层会自己检测的,不用你操心,你该成功就成功,你上层使用的挂起等待还是死循环检测跟你没关系,你选择用你的锁,没关系,申请锁成功就给您申上,不成功我就在底层给你自悬。自悬用的场景有,但我们用的并不多,因为自悬锁优缺点,一旦自悬检测的时候,他要求就是在临界区呆的时间非常短,那么对应的线程申请完之后就立马要释放,而我们在对方没有释放的时候,那我们的系统呢就会疯狂的在库里面帮我们自悬,其实是非常消耗CPU资源的,所以一定是非常笃定我们临界区里面可以用自悬,否则我们还是结合实际情况嘛,这就叫做自悬锁。 )公平锁,非公平锁?

读者写者问题

还有一个比较重要的就是读者写者问题,这个呢不想给大家重讲了,给大家把读者写者问题是什么给大家一讲一说,我们的接口一介绍就不做重点介绍了,大家有兴趣就看看,没兴趣就算了。主要是没现象,接口呢就跟加互斥锁是一样的。
》在编写多线程的时候(除了我们说的生产和消费模型),有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢? 有,那就是读写锁。配套的问题的呢就是,读者写者问题。
》下面根据一个故事可以给大家理解一下读者写者问题,然后我们对比一下读者和写着问题和生产和消费之间。不知道大家有没有在小初中做过出黑板报?我们给大家举的例子是出黑板报。有人会在黑板报上面写很多故事、实时新闻等很多看图片丰富生活。其中有一个人正在出黑板报的时候,就会有很多的吃瓜群众围绕着去看了。看的时候呢,一个出黑板报的人有可能在画某种东西,有人说是画的鸭子、鸡等,最后人家可能画的是条龙,总之呢,当一个人正在出黑板报的时候,其他一大批吃瓜群众在看的时候,读到的信息都是不正确的,为什么呢?因为出黑板报的人并没有把信息出完,所以此时所有的吃瓜群众都围观的时候看到的结果可能都不对。这叫做读取时,数据不一致问题,也就是说人家写还没写完,此时你还读了,那你就会读到不正确的数据,这是第一;第二呢,在出黑板报的时候,一个人在出黑板报,另一个人能不能也出黑板报呢?原则上呢应该是可以的,为什么呢,因为原来小时候出黑板报,左边是谁的,右边是谁的,可以这么搞,是因为黑板报被我们认为划分过。如果黑板报呢,你刚在写又来了一个人他也想写,那么此时就会将你们两个的信息搞混了 ,一个人写文章字写的非常好,另一个人画画特别好,两个人往上面搞,最后信息全部搞混,那么所以呢,两个出黑板报的人是不能同时写的,这是常识。当我们出黑板报的人出完了,有人围观说,字写的真好、画的真好,那么我们作为吃瓜群众的时候我问同学们 ,我们这批吃瓜群众之间是什么关系呢?很明显我们读者写者问题,它也是多线程当中的一种并发场景,他也要遵守321原则,现在我们基于之前给大家理顺的思考的一种方式,我来问一下大家,首先呢我们先讨论2种角色:说白了就是读者和写者;1呢:就是读写场所,因为有两个角色,消费线程和生产线程,所以对应的他们要共享资源的话,他们要有对应的读写场所给我们进行读取和写入。接下来我们重点讨论的是3:很显然写者和写者之间都要访问临界资源,也就是对我们的读写场所进行写入,所以写者和写者一定是互斥关系;那么读者和读者是什么关系呢?没有关系!我们从来没有过说,看黑板报的同学,你们几个人不能同时看,我先来的,你们先把眼睛闭上让我先看,现实生活中不存在这种情况,大家都是一块看的,你看这里,我看那里互不影响;那么读者和写着什么关系呢?常识告诉我,如果有一大批群众正在看黑板报的时候,突然有人把这个抹掉了,比如说我从前往后看,那么这个故事我一定是没有读完,那么我看到的数据呢就是片段式的,它也会有不一致的问题,所以我正在读的时候,你就不能来写,同样的当正在写的时候,此时对方也是不能来读的,所以对我们来讲呢,读者和写者显然的是互斥关系。当然还有一些情况呢我们后续再说。一般我们读者在读的时候,写者你就不能来写;写者在写的时候,你就不能来读;但是呢大家可以看到非常重要的点,一般我们在使用读者和写者场景问题的时候呢,读者和写者也没有特别明显的同步关系,不一定非得是你读完了我才能写,我写完了你才能读,也不能说没有,只能说是场景问题。我们刚开始 就说了,我们一般叫做读的机会非常多,什么意思呢,就是你把东西写好,很长时间不会再去修改,只会过了很长时间写者才会过来写。所以呢,我们写者是有很强的偶然性的,那么读者呢是不 管你写者的,你来写你就写,你不想写就拉倒,反正我能读,反正他们两个没有出现很强很强的同步关系,但是呢稍后我们将会讲,如果读写同时来了怎么办的问题。所以当我们明白这一点后呢,我们先暂时不往下面去说。
》我想问同学们一句话,根据我们之前的生产和消费者模型,首先写者和写者互斥、读者和写者互斥基本上和我们的生产和消费模型是一样的。 但是为什么我们以前的消费者和消费者之间是互斥的关系,这里为什么没有了所谓的互斥关系呢?也就是说读者写者问题VS生产和消费者本质区别是什么呢? 为什么读者和写者问题,读者和读者之间是没有竞争关系的? 而我们以前讲的消费者和消费者是有竞争关系的。当然他们的场景不一样,一般生产和消费他们的步调是一致的,因为我们本来就要用它来解决忙闲不均的问题,他们两步调不一致。但是读者和写者问题,他们的步调是严重不一致的,读者很多,而写者呢巨少,而且很少去写。读者和写者VS生产和消费模型的最本质区别:消费者会把我们对应的数据拿走,而读者不会!一旦你理解这点就清楚了,读者和写者,生产和消费本质的区别就是消费者会将数据拿走,你拿走了我消费者就看不到了,也就是你拿走了,我们的交换区里面就没有这个数据了,其他消费者就不能拿这个数据,此时当然会出现竞争了呀,为什么你拿不是我拿呢对不对,读者并不会。 所以读者和写者模型其实在逻辑上呢本质区别呢,和生产和消费模型除了场景应用呢差别很大之外,还有一个就是读者不会把数据拿走。
在这里插入图片描述

》当我们体会到这一点呢,就来看一看,所以呢对我们来讲,我们如何去理解读者和写者问题呢,给大家说一下,一般呢,我们读者很多,写者很少,所以他们要访问一部分临界资源的时候呢,首先我们要识别他们的身份,然后让他们以不同的角色去进入临界区,直接就决定了他们要进行什么操作。读者和写者对应的操作对应的伪代码大概是什么样子呢,写一下看。因为我们读者和写者不一样,会涉及到,很显然读者和读者之间他们本质上就是一个简单的就是没有关系 。所以我们一般涉及到读者—>加读锁,也就是相当于读取的时候要把我们对应的锁带上来。第二个呢,当退出的时候,这个读者就要进行释放锁 ;对于写者来讲,它要做的呢,它要做的和读者不一样,它重要的是加写锁,然后写入修改内容,然后释放锁。所以一般呢,我们对应的读者和写者模型呢非常简单,就是读者和写者前期加不同的锁,但他们两个肯定有关系,然后我怎么知道你是读者呢,原因很简单,从程序员视角呢,加了读锁的人一定是读者,加了写锁的人一定是写者。通过对临界资源的保护,读者加读锁,写者加写锁这样区分它们的身份。在我们对应的Linux当中呢,你所谓的读写锁是什么意思呢,我们来看看,一般现象不明显。
》pthread_rwlock_init()对锁进行初始化,读写锁类型呢就是:ptrhead_rwlock_t;销毁锁呢就是pthread_rwlock_destroy();其实就是将mutex换成rwlock就好了,学了一两个标准的锁后,其他的一看就会。再下面就是ptrhead_rwlock_rdlock()这就是读者你加读锁。然后pthread_rwlock_wrlock()也就是你写者加写锁。
》所以我们今天想写一个读者和写者模型代码,我们很快就把它写完了。我们写一个readerAndWriter.cc。 我们先创建两个线程,pthread_t r,w;pthread_create(&c,nullptr,reader,“reader”),pthread_create(&w,nullptr,writer,“writer”);然后我们还要等待新线程,pthread_join();当然还要写回调函数reader()、writer()。我们让读者呢就进行读,while(true){cout << "reader read: " << board << endl;};写者就一直写,while(true){board++;}这个操作呢其实明显是有问题的,但是问题呢也不大。我们让其以读者身份工作这么做呢?必须保证board被修改的时候,没有人跟他抢,你写的时候我就不读了,同样的你写的时候我就不读了,当然呢我们读者有很多,怎么办呢,我们是不是得加锁, 你现在还没有锁怎么办呢,没关系,我们定义一把全局锁,pthread_rwlock_t rw;定义好之后呢,紧接着就是初始化,pthread_rwlock_init(&rw,nullptr);然后用完之后就destroy;那我们现在呢就能够知道读者和写者就能够跑起来了,怎么办呢,让读者进行读加锁,pthread_rwlock_rdlock(&rw);在我们写者回调函数里面呢,ptrhead_rwlock_wrlock()以写的身份进行加锁,不管他们是什么身份,他们释放锁都是一样的,因为本来就能在内部识别到你们曾经是怎么加锁的,你们身份是什么,所以释放锁都叫做,pthread_rwlock_unlock()函数,他们统一都用这么一个函数来释放。这就叫做读者和写问题,很简单。一般真实的场景就是读者会非常的多。 然后你看到的现象就是大部分读者读到的都是0,那为什么写者不写呢,是因为没办法抢到锁,因为读者一直在抢锁。当然根据我们说的概念呢,如果不让你的读者先进入,先slepp(1)睡眠1秒,其中可以看到一个问题是,我们让读者先不读,我们让写者陷进去,但是我们让我们的写者在临界区待久一点slepp(10)睡眠10秒,所以我们正常情况下,应该看到的现象是什么呢?写者当前进入到临界区拿着锁去休眠,所以因为读写锁的问题,而你的读者再想来加锁,对不起读者你不能来加锁,我们会发现很长时间写者什么都不干,然后读者呢也不会去执行读任务。

#include <iostream>
#include <unistd.h>
#include <pthread.h>

int board = 0;

pthread_rwlock_t rw;

using namespace std;

void *reader(void* args)
{
    const char *name = static_cast<const char *>(args);
    cout << "run..." << endl;
    while(true)
    {
        pthread_rwlock_rdlock(&rw);
        cout << "reader read : " << board << "tid: " << pthread_self() << endl;
        sleep(10);
        pthread_rwlock_unlock(&rw);
    }
}

void *writer(void *args)
{
    const char *name = static_cast<const char *>(args);
    sleep(1);
    while(true)
    {
        pthread_rwlock_wrlock(&rw);
        board++;
        cout << "I am writer" << endl;
        sleep(10);
        pthread_rwlock_unlock(&rw);
    }
}

int main()
{
    pthread_rwlock_init(&rw, nullptr);
    pthread_t r1,r2,r3,r4,r5,r6, w;
    pthread_create(&r1, nullptr, reader, (void*)"reader");
    pthread_create(&r2, nullptr, reader, (void*)"reader");
    pthread_create(&r3, nullptr, reader, (void*)"reader");
    pthread_create(&r4, nullptr, reader, (void*)"reader");
    pthread_create(&r5, nullptr, reader, (void*)"reader");
    pthread_create(&r6, nullptr, reader, (void*)"reader");
    pthread_create(&w, nullptr, writer, (void*)"writer");


    pthread_join(r1, nullptr);
    pthread_join(r2, nullptr);
    pthread_join(r3, nullptr);
    pthread_join(r4, nullptr);
    pthread_join(r5, nullptr);
    pthread_join(r6, nullptr);
    pthread_join(w, nullptr);

    pthread_rwlock_destroy(&rw);
    return 0;
}

》我们最多能感受到的就是读者和写者是互斥,读者之间的关系无关,你下面都是几个写者也是能知道是互斥的。但是读者和读者间关系很明显,他们是并行跑的。下面再带大家理解一下是怎么做到的,它是怎么做到,让读者实现并发,写者是互斥的呢?
》一般读写锁呢可以理解成,写一个伪代码;
我们有一个int readers = 0;计数器,然后我们的struct rwlock读写锁的结构体,里面有int readers = 0;然后他自己在锁的时候有一个身份,int who;mutex_t mutex;那所谓的读者是什么样的呢,你可以理解成lock(mutex),你申请到了锁,说明当前没有人访问这个临界资源,另外你对应的是读者,所以你当时加锁的函数是怎么加的呢,你用的是pthread_rwlock rdlock()我申请到了锁,然后我一定是读者,是读者的话就readers++;然后unlock(mutex);后面就是read读操作了。解锁呢,就是lock(mutex),readers–;unlock(mutex);其中这个就相当于呢,读者加锁的时候,只要它是读者,它进来,每一个读者都是要进行改变一下计数器就行了;写者进来是要怎么做呢,pthread_rwlock_wrlock(),因为它是写者,所以它也要申请mutex,lock(mutex),因为读者和写者申请的是同一把我们对应的mutex,那么这里是不是就意味着在一定程度上,读者和写者能保证竞争关系,互斥嘛,肯定要申请同一把锁,然后怎么办呢,然后此时我们写者要做的事情就是if判断,if(readers > 0 )直接进行释放锁,wait,直接就去等了。你也可以理解成while检测,即while(readers > 0),你就这理解,只要当前的reader读者是>大于0的,那说明有读者,怎么办呢,这个写者就必须得释放锁进行等待。当继续往后走的时候,同学们你也知道wait回来的时候会再将锁拿上,拿上之后怎么办呢,拿上之后再进行自己的write操作,操作完毕之后再进行自己的unlock()释放锁。当我们读者和写者,一旦有读者先进来,此时的写着就进不来,一旦读者把计数器++了,这个写者你再怎么能耐也拿不到锁,只要读者不退出,你这个写者也没办法继续访问。只有当读者全部减到了0之后,此时你条件满足,你才会继续往后走执行写操作,然后再解锁。这就可以理解成,读加锁和写加锁的一个基本原理。当然这是伪代码说明原理,他其实并不严谨,但是能改明白意思就行,其实还有比较复杂的工作,什么工作呢,一般我们在读加锁的时候,根据你给我的表诉,读者和写者进行操作的时候,我们明显特点读者特别多,频率特别高;对于写者呢,写者特别少,频率低,也就是大部分情况都在读。写者来了就来了,这个时候有一个什么问题呢,你可以想像一下,当写者到来的时候,完全写者来的时候和读者是没有很强的同步关系的,我读者说,你来了,你要干什么呢,要写是吧,先别急,让我先读完,但是我读完了不代表你就可以去写了,我作为一个读者读完不能代表你就可以去写了,为什么呢,因为可能还有其他读者在读,所以读者和写者呢,写者来的时候,它可能随时都能来,但是呢他和读者之间没有明显的同步关系 。所以对我们来讲呢,在这里有点类似同步关系的概念,但是比同步关系呢,不想生产和消费,必须你做完我做,我做完他做,不是的。
》读者和写者问题呢在一般读写时总会面临一个问题,即便读者再多,写者再少,万一读写同时来了呢?此时按照同学们想法,你今天给的概念,读者和写者,读者人多呀,只要计数器不为0,这个写者就根本没办法进来,好吧,那你读者操作我们对应计数器的时候, 加锁很短,一瞬间有大量的读者来的时候,这个写者就特别的难,有可能有什么问题呢,读者一直都有,不仅一直有还一直来,所以长时间呢写者加不到锁,读者不断的来,计数器不断++,最后容易造成写者饥饿问题。下面说一下,我们的这个读写锁,它默认就是读者优先的,默认就有这个问题,这是很正常的,因为你写的人本来就很少,你饥饿已经不重要了,为什么呢,因为你本来就慢,我把你延迟一下呢不影响,所以对我们来讲,写者饥饿问题我们很少去考量,但是我们不得不去面临读者和写者同时来了怎么办,所以当读写同时到来的时候,我们一般会有两种优先级策略,我们称之为,读者优先,写者优先。什么叫读者优先呢,就是读写同时到来的时候,我们此时竞争锁的时候,我们优先让读者先拿到锁,这个时候就要判断身份了,其实就是根据你调用不同的锁类型来识别你的身份,我们优先让我们的读者先来。与此同时呢,如果读者没有读完,此时读者呢就一直要读,后来呢,你写者来了,你读者必须得等,等所有的读者读完之后,你才能写, 这叫做读者优先。还有一个呢叫做写者优先,你再想想你这个写者优先怎么优先呢,你再怎么优先,无外乎做到一点,哪一点呢,当我们读写到来的时候,写者来了,你再怎么优先,当前临界资源里面照样可能会有读者正在读,你优先呗,你想怎么优先,你不让这些读者读了吗?不好意思你做不到,所以你再怎么优先,不可能不直接把别人抢过去。所以写者优先最大特点,同学们可以理解成,当我们有很多的读者,突然来了一个写者,那么此时呢我就通过策略去限制,往后在写者之后到来的读者,暂时就不要进入这个区域了,暂时不要进入我们的临界资源,等当前要读的人读完了,先让写者优先去写,写完之后你们再读,这叫做写者优先。
在这里插入图片描述

》所以我说,我们写的读写锁是一个婴儿版本,为什么呢,因为它没有我们刚刚说的策略。所以再说一下,关于读者和写者问题,以及读写锁呢,我们只要掌握了上面说的点就完去够了,一般在这种情况下呢,只要能够使用读写锁,原理能知道,然后和生产和消费模型差别能清楚,基本上也就够了。有人说什么情况下你能够给我举一个例子吗?比如说生产和消费呢我内心也不知道有什么场景,但是呢读者和写者能不能给我举一些场景。比如说什么是读者和写者最典型的应用呢,就比如说大家平时看的新闻,新闻的消息一经发布就很少被修改了,顶多改改特别字,大部分情况下是被读取的,这种场景呢就是读者和写者问题。有人说策略怎么选择,你还是不要选择了,因为我们之前经过常识POSIX库的读写优先,效果特别不明显而且内部好像还有bug,因为我们用的原生线程库呢读者和写者优先支持的并不好,所以呢我们就使用默认的策略就可以了,至于你将来在应用上,你觉得你想要读者和写者优先,默认是读者优先的,你想写者优先可以,此时取决于场景,比如说你的新闻想要尽快的去修改只能是调整为写者优先。其实我们也知道,即使你吧写者优先调整了,如果万一这个消息一直被大量读者读取,虽然在写者之后来的读者进不去,但是大部分读者正在进行读取,这个也是要花时间的,但是总好过让读者一直进去。
》系统部分还有一些细节话题呢,打算以类似小课的方式给大家导入,可能不多,但是可以看看。
》面试的时候总会遇到linux你不会的问题,因为东西太多了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值