本节主要介绍进程和线程同步的方式
1.线程同步之互斥量
场景:当多个线程争抢一个临界资源(临界资源是可由多线程或多进程共享的)的时候,互斥量可以保证一个线程在使用这个临界资源的时候,其他线程不会使用这个资源。
例如在生产者消费者模型中:
两个线程都在修改一个内存里面的数据k,而且他们是并发的修改。
此时生产者线程的操作是
register = k # 数据拷贝一份到寄存器
register = register+1 # 寄存器内数据+1,这一步是在CPU中执行,得到的左边的register是放在寄存器暂存的
k = register # 将寄存器的数据覆盖到缓冲区
而消费者线程的操作是对k减一:
register = k
register = register-1
k = register
我们知道线程在并发执行的时候是会被分配时间片的,一个线程执行一段时间片,就把CPU让出给另一个线程执行一段时间片。
如果,生产者执行到
register = k
register = register+1 # 时间片结束
CPU让出给消费者线程执行
register = k
register = register-1
k = register # 时间片结束
CPU让出给生产者执行
k = register
假如k初始值为0,最后执行结果本应该还是0,但最终却是1
可以看出来,多线程中线程的指令交叉执行,如果交替执行的数据是同一个数据就会导致数据混乱(脏数据)。
互斥量则可以保证操作指令的原子性,让针对某个资源的一系列操作不中断的执行完再让其他线程执行指令。
互斥量是线程同步最简单的方法,又称为互斥锁(互斥量不是互斥锁,互斥量是指资源,互斥锁是指对互斥量所上的锁)。
互斥锁是通过对资源加锁和释放锁来实现资源访问的串行和操作的原子性。
当一个线程对资源加锁后,其他线程无法操作该资源;线程对资源解锁后,其他线程才可以拿到这把锁对该资源加锁并访问。
通过加锁和释放锁可以保证资源访问是串行的(一个线程访问完再让下一个资源访问)。
操作系统直接提供了互斥量的API,开发者可以通过这个API完成资源的加锁和解锁操作。
这个api是pthread_mutex_t
当然,加锁和解锁是有一定的性能损耗的,所以只论单线程,加锁执行起来会比不加锁慢。
互斥锁的原理是通过保持指令执行的原子性和资源访问的串行来实现的(代码中加了锁的那部分代码,多线程是串行的;没加锁的那部分代码,多线程是并行的)。
实例:
上述代码中,在执行对num这个临界资源的修改时会上锁,此时一个线程对n修改,另一个线程就不会对n修改(n已被锁定)
下面说说本人对互斥锁和上面这段程序的理解:
在上面的多线程程序中,当一个线程上了锁后执行操作,while中的代码执行了一半还没有执行完锁内的逻辑时,时间片就已经用完,CPU交给另一个线程使用;另一个线程对数据的操作也是要上锁的,它尝试获取锁,但是由于之前那个线程没有解锁,所以这个线程也会睡眠等待,等待意味着他会让出CPU给之前的线程;之前的线程继续运行,运行到解锁,此时另一个线程能够上锁并从上一次进入等待状态的那行代码继续运行
PS:上锁的时候,只锁数据,不要锁住方法或者条件判断;锁数据的时候,尽量只锁住共享数据,不要锁住其他线程独立的数据。上锁的时候,锁住的代码越少,效率就越高,执行的越快。因为上了锁的那部分代码相当于是多线程串行,所以锁住的代码越多,串行的部分就越多,效率就不高
只锁住共享数据不锁独立的数据意思指:
假如有A和B两个线程,有a,b,c三个数据。
A要做的是while循环让a++,c++
B要做的是while循环让b--,c--
此时A和B共享的数据是c,所以只需对c--和c++这两行代码加锁,A,B无需对a和b这两个变量加锁。否则如果A对a++和c++这两行代码都上锁了。A对a修改的时候,B就无法对b或c作出修改(B这个时候是可以对b或者c变量修改的)
用代码表示就是:
# A线程 错误示范
while(times--){
pthread_mutex_lock(&mutex); # 上锁
a++;
c++;
pthread_mutex_unlock(&mutex) # 释放锁
# A线程 正确示范
while(times--){
pthread_mutex_lock(&mutex); # 上锁
c++;
pthread_mutex_unlock(&mutex) # 释放锁
a++;
B线程也一样,只对b++上锁,不要对c++上锁
只锁数据,不锁锁住方法或者条件判断的意思是不要调用一个函数时不要锁住整个函数调用,而是锁函数中部分真正需要锁住的操作。不要先上锁再做判断,而是先做判断,在判断里面上锁
如:
# 错误示范
pthread_mutex_lock(&mutex); # 上锁
func();
pthread_mutex_unlock(&mutex)
# 错误示范
pthread_mutex_lock(&mutex);
if(...){
...
}
pthread_mutex_unlock(&mutex)
# 正确示范
if(...){
pthread_mutex_lock(&mutex);
...
pthread_mutex_unlock(&mutex);
}
在循环的时候,千万不要在循环外上锁,而是在数据做修改的时候上锁
错误示范
pthread_mutex_lock(&mutex);
while(times--){
num+=1;
}
pthread_mutex_unlock(&mutex);
正确示范
while(times--){
pthread_mutex_lock(&mutex);
num+=1;
pthread_mutex_unlock(&mutex);
}
如果将锁放在循环外,那么循环开始前加锁,要等到循环结束才能释放锁,这意味着其他线程在这个线程的循环结束前都拿不到锁,都要等待不能执行。如果这个循环很长,那么这个线程会占着CPU一直运行(即使该线程用完时间片让出CPU,其他线程被阻塞,照样主动让出CPU,把CPU拿回给原来这个做长循环的线程用),其他线程得不到运行,导致相当于变成单线程。
PS2:如果两个线程都要操作一个数据n,为了线程安全所以加锁,此时必须两个线程都对操作数据n的代码加锁,不能一个线程加锁,另一个线程不加。这样线程A把n锁住了,线程B还是可以操作到n,因为B操作n不用加锁就能直接操作n
还有,两个线程要加同一把锁才能保证数据的安全,不能A线程操作数据n的时候加锁lock1,B线程操作n的时候加锁lock2;因为 互斥锁之所以能够锁住数据就是由于多个线程共用一个锁的缘故。如果A用锁1锁住资源,B用的是锁2,不用获取锁1,B把锁2加到资源上,然后就可以直接操作n。
所以,两个线程用两个不同的锁就和不用锁一样会造成数据混乱。
上面的例子中,producer和consumer线程都是用的同一个锁 (变量mutex)
2.线程同步之自旋锁
自旋锁也是通过线程对临界资源加锁做到资源的串行访问。
和互斥锁的区别:
使用自旋锁的线程会反复检查锁是否可用;
自旋锁不会让出CPU,是一种忙等待状态,意味着A线程对数据X加了锁后,线程B想操作X,于是要等待A线程释放锁,但是这个等待过程B线程不会让出CPU,而是一直运转CPU去判断A有没有释放锁,直到判断A释放了锁后B才获取到锁并开始对数据X操作.
自旋锁避免了进程或线程的上下文切换的开销
操作系统内部很多地方使用的是自旋锁
自旋锁不适合在单核CPU的计算机中使用
pthread_spinlock_t
举个例子吧:
还是生产者和消费者模型:
假如一个进程中有3个线程
生成者线程:A
lock # 加锁
register = k
register = register+1
k = register
unlock # 解锁
消费者线程:B
lock # 加锁
register = k
register = register-1
k = register
unlock # 解锁
第三个线程:C
m++
三个线程并发执行。
如果这个锁是互斥锁:
那么 A执行到register = register+1后,时间片用完,轮到B执行,但是原子性操作没有执行完所以A没有解锁,B无法获取锁于是休眠,休眠的时候会让出CPU。
于是CPU轮到C执行,C是对资源m操作没有涉及到k,所以无需理会锁的问题,无需等待锁释放,直接操作变量m。
C执行完轮到A将上一次的原子性操作执行完,A解锁,B得到通知被唤醒,B获取锁并执行指令
互斥锁的问题在于:CPU让B休眠和把B唤醒都要有所损耗,要消耗一点点时间,切换次数多了损耗也会挺大。
如果这个锁是自旋锁:
A执行到register = register+1后,时间片用完,轮到B执行,但是原子性操作没有执行完所以A没有解锁,B无法获取锁,但是B不会休眠不让出CPU,而是一直执行获取锁的操作,这个操作是很消耗CPU的,可以理解为B不断尝试获取锁的过程是逻辑运算的过程(即死循环)。直到B的时间片结束,CPU会强制切给C线程,C执行完再给A执行。A完成原子性操作,解锁,轮到B执行,B就能够对操作加锁执行。
上面是单核的结果,如果是多核,A,B并行而不是并发。那么资源被锁住,B在不断获取锁而且的时候,A也在执行,完成了原子性的操作,B就能获取到锁。
所以可以看出,单核使用自旋锁是完全没意义的,因为在A没释放锁的情况下,B在自己的时间片内根本不可能获取到锁(因为A根本无法释放锁,A无法释放锁是因为A线程没有在执行,A没有在执行是因为只有一个CPU且CPU交给B在执行),同时B还会大量消耗CPU。(使用top命令看到CPU占用100%)
单核的计算机中使用自旋锁进行多线程同步的话,等同于使用单线程,而且比单线程还不如,因为处于等待的线程不干活却又大量消耗CPU资源
自旋锁和互斥锁都是防止并发访问共享数据时可能导致的数据不一致问题。
实例:
为了查看自旋锁和互斥锁的区别,作者在 producer 函数中每次循环都 sleep(10)。这样producer线程每次循环都会睡10秒,而且sleep(10)是放在锁里面的,所以producer线程的每次循环都会被锁定10秒。消费者无法获取锁,于是在其时间片内不停的跑CPU去尝试获取锁。
这个过程中CPU占用率达到100%
而如果是使用互斥锁,消费者遇到锁未释放的情况,就会睡眠,让出CPU。
所以自旋锁不适合用于存在比较多的等待或者阻塞的任务(如爬虫,磁盘IO读写等)和不适合单核的环境下使用,因为会大量消耗CPU,而且是无用的消耗。
3.线程同步之读写锁
是对自旋锁和互斥锁的改进。
读写锁是一种特殊的自旋锁。读写锁的出现是考虑这样的场景,临界资源经常被读取,很少被修改。这样的话读取的时候就无须阻塞等待,读取可以并发或者并行。
读写锁包括两种锁:读锁和写锁
线程对临界资源读取的时候,需要我们对操作加读锁
线程对临界资源修改的时候,需要我们对操作加写锁
资源可以被添加多把读锁,只能被添加一把写锁
线程对资源加读锁时,其他线程也可以对资源加读锁。所以线程对资源读取时,其他线程也可以对资源读取。
线程对资源加读锁时,其他线程也不可以对资源加写锁。即读取时其他线程不能写。此时执行写的线程会自旋,不停的尝试获取锁。
线程对资源加写锁时,其他线程也不可以对资源加读锁或写锁。即写时其他线程不能对资源写或者读。
读读 yes 可并行或并发
读写 no 只能串行
写读 no 只能串行
写写 no 只能串行
读写锁的适用场景是多读少写的任务场景。
例如:
有两个任务:一个是循环100000000次print(k),一个是循环100000000次k++
为了模拟读多写少的场景,这里使用2个线程进行读,1个线程进行写
用A,B两个线程执行任务一,每个线程都循环100000000次
用C线程执行任务二
同步方法1,使用读写锁:
任务1加读锁
任务2加写锁
同步方法2,使用互斥锁:
任务1加互斥锁
任务2加互斥锁
结果法1消耗时间远远小于法2
实例:
writer的循环内的代码执行时,reader循环内的代码会等待(而且是以CPU空转的方式等待,会消耗CPU),因为写锁锁定了资源n,无法给n上读锁
线程1的reader执行时,线程2的reader,和线程3的writer可以执行,因为对资源加了读锁后,其他线程依然可以对其加读锁和写锁。当然如果是线程3比线程2先一步加了写锁,那么线程2就不能加读锁
如果上面的例子将读写锁改为互斥锁,会发现执行时间长了很多,因为读取的时候也上了锁,其他线程无法读,只能等待。
4.线程同步之条件变量
条件变量的原理是 允许线程睡眠直到满足某种条件另一个线程就会就向该线程发送信号,通知唤醒。
条件变量要配合互斥量使用
举个例子:
有一个队列,里面放着要执行的任务
当生产者生产的速度超过消费者消费的速度,队列中的任务会堆积。假设队列能容纳的任务个数是10000个。
假设现在队列中的任务个数为n
现在的需求是:当n>=10000时,让生产者线程停止生产进入休眠,当消费者线程消费掉队列中的任务使n<10000,消费者就会发送信号给生产者线程唤醒生产者继续生产;当队列元素==0时,让消费者停止消费进入休眠,然后生产者生产任务令n>0,并发送信号给消费者,唤醒它进行消费;
这里面的条件变量就是 队列中现有的元素个数
实例:
详细流程:
假设,只有一个生产者线程和一个消费者线程,两个线程并发。
设定产品最大容量为100,当产品数量达到100时,产品停止生产,让消费者消费掉一些产品,使产品数量小于100时,生产者恢复生产。
当产品数量达到0时,消费者停止消费,让生产者生产一些产品,使产品数量大于0时,消费者恢复消费。
a.产品数量在 0~100 之间的情况下:
当生产者进行生产按顺序会:
执行A[先对资源上锁]
执行Do1[生产产品]
执行C[发送信号,但是没有实际作用,因为消费者并没有在等待信号]
执行D[解锁]
这个过程中,消费者无法对num修改。这个过程和仅用互斥锁的效果没有任何区别
当消费者进行消费按顺序会:
执行E[先对资源上锁]
执行Do2[消费产品]
执行G[发送信号,但是没有实际作用,因为生产者并没有在等待信号]
执行H[解锁]
这个过程中,生产者无法对num修改。这个过程和仅用互斥锁的效果没有任何区别
情况a就是单纯的使用了互斥锁,没有用到条件变量
b.产品数量达到100的情况下:
当整体执行顺序为:
生产者执行A[生产先对资源上锁]
执行Check1[判断到产品达到上限]
执行过程B[过程B的pthread_cond_wait()干这么几件事情:1.阻塞当前生产者线程,不让他执行后面的num+1的生产操作,此时生产者线程会休眠,让出CPU,休眠的过程其实是在等待信号,等待到信号就可以被唤醒;2.在生产者线程休眠之前释放互斥锁,让消费者可以消费掉一些产品,如果生产者不释放互斥锁,消费者是无法获取锁也就无法对num-1的]
由于生产者线程让出CPU,时间片没执行完就让出CPU给消费者线程执行。
消费者执行E[先对资源上锁(生产者的pthread_cond_wait()已经释放了锁,所以消费者这里可以上锁)]
执行Do2[消费产品]
执行G[发送信号给生产者,唤醒生产者,告诉它可以继续生产了]
执行H[对消费者线程解锁]
生产者线程继续过程B[此时的pthread_cond_wait()干这么几件事情:1.接收到消费者的信号,休眠被唤醒,解开阻塞;2.重新为生产者线程上互斥锁]
执行Do1[生产产品]
执行C[发送信号,但是没有实际作用,因为消费者并没有在等待信号]
执行D[解锁]
上面生产者线程,消费者线程和条件变量使用的都是同一个互斥锁
关于条件变量的几个问题:
0.为什么条件变量要配合互斥锁使用?
首先我们要知道,条件的判断不在pthread_cond_wait函数中进行,而是在外部的while 中进行。
而且条件变量num是一个共享资源。
所谓的条件变量配合互斥锁是指进行条件变量的判断(while num<=0)和线程等待(pthread_cond_wait)要放在锁内( pthread_mutex_lock和 pthread_mutex_unlock 之间)
假如不这样做很可能会造成条件判断成立后(while之后),线程进入等待前(pthread_cond_wait之前),这个条件被其他线程改变(例如有线程对num+1使得num=0变为num>0)。这样一来,线程无需进入等待,但是却还是进入了等待。
1.pthread_cond_wait()做了些什么?
pthread_cond_wait()做了两件事:
a.释放互斥锁
b.让本线程睡眠
pthread_cond_wait会保证这两件事是一个原子操作,意味着pthread_cond_wait()释 放锁之后和睡眠之前,其他线程不会拿到锁,也不会调用 pthread_cond_signal()发送信号
如果这两件事不是一个原子操作:例如,生产者线程要进行等待,pthread_cond_wait()先进行了解锁,这时候由于该线程时间片到期,没来得及让线程休眠,就切到消费者线程;消费者线程拿到了锁(因为生产者释放了锁),并消费了产品,发送信号,解锁。
由于生产者没有来得及睡眠,所以消费者线程发送的信号会被忽略,也就是信号丢失。
此时CPU切换回生产者线程,生产者线程继续刚刚没完成的休眠。此时生产者让出CPU,消费者线程要再消费一次,发送信号,才能唤醒生产者。
也就是说,如果a,b不是原子操作,会导致其他线程发送的信号被忽略,本线程开始没必要的休眠
本线程睡眠后 pthread_cond_wait() 还没有返回,等本线程接收到信号,pthread_cond_wait()会做:
c.唤醒本线程
d.重新加锁
2.关于代码中的 Check1 和 Check2 中,为什么要用while而不是用if?
这样是为了避免虚假唤醒(例如执行pthread_cond_broadcast时,所有线程的等待都会被唤醒),此时某些线程的条件是不满足唤醒条件的,所以要再判断一次条件,如果不满足唤醒条件则再执行pthread_cond_wait()重新进入等待。while可以重复判断而if只能判断一次。
所以不使用while而使用if的话,遇到的最常见的报错就是,"队列已经为空,不能够再取出元素"
3.在生产者线程等待时,消费者线程的发送信号是放在锁内还是锁外?
例如:
发送信号放在锁内:
pthread_mutex_lock(&mutex) //加锁
// ...
pthread_cond_signal(&cond) //发送信号
pthread_mutex_unlock(&mutex); // 解锁
发送信号放在锁外:
pthread_mutex_lock(&mutex) //加锁
// ...
pthread_mutex_unlock(&mutex); // 解锁
pthread_cond_signal(&cond) //发送信号
前者:发送信号后,生产者线程唤醒,但消费者的时间片还没用完,消费者会执行下面的解锁;然后生产者线程进入执行状态,发现锁已经释放,于是生产者加锁,执行后面的操作
如果发送信号后,消费者的时间片用完,消费者没来得及解锁,生产者就开始执行;生产者线程被唤醒,尝试加锁,但是消费者没解锁,于是生产者再次进入阻塞状态,让出CPU;消费者拿到CPU,继续执行解锁,之后生产者才能顺利加锁
后者:消费者先解锁后发送信号可以保证生产者被唤醒后可以马上拿到锁
但是如果在消费者解锁后,发送信号前,有其他线程拿到锁,那么生产者线程还是要等待那个其他线程释放锁
但无论如何,生产者线程被唤醒后无论如何都是能拿到锁的(即使可能这个锁被别人拿走了,也只是等一会就能拿到锁),所以实际开发中不用太在意这个细节。
4.如果有多个线程在等待,当某个线程发出通知 pthread_cond_signal() 的时候,是只有一个线程被唤醒还是所有线程被唤醒?
首先我们要知道,当一个线程在执行 pthread_cond_wait 的时候,线程会释放锁,然后进入休眠状态。此时这个线程会被放到一个 条件等待队列 里面。如果有多个线程使用的是同一个条件变量,这些线程会按顺序的放到同一个队列中进行等待。
执行 pthread_cond_signal()唤醒的时候,会从队列中取出一个线程进行唤醒。
所以唤醒的时候只会唤醒一个线程,而且唤醒哪个线程也是有顺序的,因为这些等待线程是放在队列中的。
如果是执行 pthread_cond_broadcast() 广播,则会唤醒所有线程。
在这个例子中只有两个线程,如果是条件变量涉及到多个线程会更复杂。
====================================================
使用fork创建进程
fork创建的进程初始化状态和父进程一样,包括他们拥有的变量,空间
系统会为fork出来的进程分配新资源。
fork()没有参数,会返回两次,分别返回子进程id和0。两个返回值分别是父进程和子进程返回的。
返回子进程id的是父进程,返回0的是子进程,可以根据这个判断和分辨父进程和子进程
上面的代码中:
一个父进程创建了一个子进程。
子进程和父进程并发运行(如果是单核的话)
系统会拷贝一份父进程的内存空间和状态给子进程,所以父进程和子进程都有变量num
由于是拷贝了一份内存空间,所以父进程和子进程的num变量是各自放在两块空间的。两个num指向的两块空间,所以父进程的num做减法会越来越小,子进程的num做加法会越来越大,两个num独立互不干扰
如果父子进程中有更多变量,这些变量也是互不干扰的,因为他们是放在不同的两块内存中。
无论是什么语言,在底层实现创建一个进程都是使用操作系统的fork()创建进程的。
进程同步之共享内存
我们知道每一个进程都有一块属于自己的一块内存空间,进程通过段页式管理,将段页这样的逻辑空间映射到物理内存这样的物理空间。
多个进程使用物理内存的时候,从逻辑上每个进程的内存空间是独立的,每个进程的逻辑空间只是映射到物理内存的一小块空间,而这些空间不会重叠。
所以:一个进程默认是不能访问其他进程的内存空间的。
为了让多个进程之间的同步和通信,提出来共享内存。
让多个进程各自的逻辑空间(页表)映射到物理内存的同一块物理空间
共享内存是两个进程间共享和传递数据最快的方式
但是共享内存未提供同步机制,需要借助其他机制管理访问
使用共享内存要以下几个步骤
1.申请共享内存
2.让多个进程的逻辑空间映射到这块共享内存
3.使用共享内存
4.解除映射,并删除这块共享内存(释放共享内存)
共享内存是高性能后台开发中最常用的进程同步方式
进程同步之Unix域套接字
域套接字是一种高级的进程通信方法
套接字(socket)原来是网络通信中使用的术语
unix系统提供的域套接字提供了网络套接字类似的功能
步骤:
对于服务端而言:
1.创建套接字
2.绑定套接字(bind)
3.监听套接字(listen),例如监听有没有连接或者请求进来
4.接收和处理信息
对于客户端而言
1.创建套接字
2.连接套接字
3.发送请求
在之后的文章中,作者会使用python来实现上面进程和线程的同步方式的实践