多线程和线程同步
线程
1.线程的概述
线程是轻量级进程(LWP: light weight process),在Linux环境下其本质仍是进程。在计算机上运行的程序是一组指令及指令参数的组合,指令按照既定的的逻辑控制计算机的运行。操作系统会以进程为单位,分配系统资源,可以这样理解,进程是资源分配的最小单位,线程是操作系统调度执行的最小单位。
概念上了解一下进程和线程的区别:
-
进程有自己独立的地址空间,多个进程共用一个地址空间
- 线程更加节省资源,效率不仅可以保持,效率可以更高
- 在一个地址空间中多个线程独享:每个线程都有属于自己的栈区,内存器(内核中管理)
- 在一个地址空间中多个线程共享:代码段,堆区,全局数据区,打开的文件(文件描述符)都是线程共享的
-
线程是程序执行的最小单位,进程是操作系统中最小的资源分配单位
- 每个进程对应一个虚拟地址空间,一个进程只能抢占一个cpu时间片
- 一个地址空间中可以划分出多个线程,在有效的资源基础上,能够抢更多的cpu时间片
-
CPU的调度和切换:线程的上下文切换要比进程快的多
上下文切换:进程/线程分时复用CPU时间片,在切换之前会将上一个任务状态保存,下次切换回这个任务时,加载这个状态继续运行,
任务从保存到再次加载这个过程就是一次上下文切换 -
线程更加廉价慢启动速度更快,退出也快,对系统资源冲击小。
在处理多任务程序的时候使用多线程比使用多进程更有优势,但是并不是越多越好,那么就需要对线程进行控制:
- 文件IO操作:文件IO对CPU利用率不高,因此可以分时复用CPU时间片,线程的个数=2*CPU核心数(效率最高)
- 处理复杂算法(主要是CPU进行运算,压力大),线程个数 = CPU核心数(效率最高)
2.创建线程
每一个线程都有一个唯一的线程ID,ID类型为pthread_t,这个ID是一个无符号长整型数,如果想要得到当前线程的线程ID可以调用以下函数:
1 | pthread_t pthread_self(void); //返回当前线程的ID |
在一个进程中调用线程创建函数,就可以得到一个子线程,和进程不同,需要给每一个创建出的线程指定一个处理函数,否则这个线程无法工作
1 |
|
- 参数:
- thread:传出参数,是无符号长整型数,线程创建成功,会将线程ID写入到这个指针指向的内存中
- attr:线程的属性,一般情况下使用默认属性即可,写NULL
- stsrt_routine:函数指针,创建出的子线程的处理动作,也就是该函数在子线程中执行。
- arg:作为实参传递到start_routine指针指向的函数内部
- 返回值:成功返回0,失败返回对应错误代码。
示例:
1 |
|
3. 线程退出
在编写多线程的时候,如果想要线程退出,但是不会导致虚拟地址空间释放(针对于主线程),我们可以调用线程库中的线程退出函数,只要调用了该函数线程马上就会退出,并且不影响其他线程的正常运行,不管是在子线程还是主线程中都可以使用
1 |
|
- 参数:线程退出时携带的数据,当前的子线程的主线程会得到该数据。如果不需要使用,指定为
NULL;
示例(可以在任意线程需要的位置调用该函数):
1 |
|
4.线程回收
线程和进程一样,子线程退出的时候内核资源主要由主线程回收,线程库中提供线程回收的函数叫做pthread_join(),这个函数是一个阻塞函数,如果还有子线程在运行,调用该函数就会阻塞,子线程退出函数解除阻塞进行资源的回收,函数被调用一次,只能收回一个子线程,如果有多个子线程则需要循环进行回收。
另外通过线程回收函数还可以获取到子线程退出时传递来的数据,函数原型如下:
1 |
|
- 参数:
- thread:要被回收线程的ID
- retval:二级指针,指向一级指针地址,是一个传出参数,这个地址存了pthread_exit()传递出的数据,如果不需要可以指定为NULL
- 返回值:成功回收为0,否则为错误码
示例:
1 |
|
5.线程分离
在某些情况下,程序中的主线程有属于自己的业务处理流程,如果让主线程负责子线程的资源回收,调用pthread_join()只要子线程不退出主线程就会被一直阻塞,主线程的任务也就不能被执行了。
在线程函数中为我们提供了线程分离函数pthread_detach()。调用这个函数之后指定的子线程就可以和主线程分离,当子线程退出的时候,其占用的内核资源就被系统其他的进程接管并且回收了。线程分离之后在主线程中使用pthread_join()就回收不到子线程资源了。
1 |
|
示例:
1 |
|
6.其他线程函数
6.1线程取消
线程取消的意思是在某些特定情况下在一个线程中杀死另一个线程。使用这个函数杀死另一个线程需要分两步:
- 在线程A中调用线程取消函数
pthread_cancel,指定杀死线程B,此时线程b是死不了的 - 在线程B中进程一次系统调用(从用户切换到内核区),否则线程B可以一直运行。
1 |
|
- 参数:要杀死的线程的ID
- 返回值:调用函数成功返回0,失败返回非0数。
其中实现系统调用有两种:
- 直接调用(直接调用Linux中的能力)
- 间接调用(使用c标准库的函数,其中有些能力是直接调用系统的函数的如
Printf)
6.2线程ID比较
在Linux中线程ID的本质是一个无符号长整型数,因此可以直接使用比较操作符比较两个线程ID,但是线程库是可以跨平台使用的,在某些平台上Pthread_t可能不是一个单纯的整型,这种情况下比较两个线程的ID,必须要使用比较函数,函数原型如下:
1 |
|
- 参数:t1和t2是要比较的两个线程的id
- 返回:如果两个ID相等返回非0值,不等就返回0
线程同步
1.线程同步的概念
假设有四个线程A、B、C、D,当前一个线程A对内存中的共享资源进行访问的时候,其他线程B,C,D都不可以对这块内存进行操作,直到线程A对这块内存访问完毕位置,B,C,D中的一个才能访问这块内存,剩余的两个需要继续阻塞等待,以此类推,直至所有的线程对这块内存操作完毕。线程对内存的这种访问方式就称之为线程同步。而同步并不是多个线程同时对内存进行访问,而是按照先后顺序依次序进行的。
示例:
1 |
|
如果使用两个线程区同时执行对一个全局变量进行加法操作,各操作50次,那么两个函数结束后最后的变量是小于100的,此时数据混乱。
原因:两个线程在数数的时候需要分时复用CPU时间片,并且测试程序中调用了sleep()导致线程的cpu时间片没用完就被迫挂起了,这样就能让CPU上下文切换更加频繁,更容易在线数据混乱的情况。
其中CPU对应寄存器、一级缓存、二级缓存、三级缓存是独立占用的,用于存储处理数据和线程状态信息,数据被CPU处理完需要再次被写入物理内存中,物理内存数据也可以通过文件IO写入到操作磁盘中。
在测试程序中的两个线程共用全局变量number当线程变成运行态后开始数数,从物理内存加载数据,然后将数据放到CPU进行运算,最后将结果更新到物理内存中。如果数数的两个线程都可以顺利完成这个流程,那么得到的结果肯定是正确的。
在执行过程中如果A在执行过程中失去了时间片就会被挂起来,最新数据就没被更新到物理内存,此时B就不会拿到最新数据,就只能基于旧数据继续进行,后续如果再轮到A执行,那么A就会将上次未更新的状态更新到内存,会覆盖B已经更新的数据。最终会导致很多数据会被重复很多次
1.2线程同步方式
对于多线程访问共享资源出现数据混乱的问题需要进行线程同步。
线程同步有四个方式:
- 互斥锁
- 读写锁
- 条件变量
- 信号量
其中共享资源就是多个线程共同访问的变量,其中包括全局变量或者堆区变量,这些变量对应的共享资源也被称为临界资源,找到临界资源后,临界资源的相关的上下文代码块被称为临界区(越小越好),确定了这块代码位置就可以进行线程同步了。
处理思路:
- 在临界区代码上边,添加加锁函数,对临界区加锁。
- 哪个线程调用这个代码,就会把对应的临界锁加上其他线程就会被这把锁阻塞
- 在临界区代码下面加上解锁函数,将临界区解锁。
- 出临界区的线程会将临界锁打开,其他抢到锁的线程就可以进入临界区了。
- 通过锁机制可以保证临界区代码最多只有一个线程进行访问,并行访问就变成串行访问了
互斥锁
互斥锁是线程同步最常用的一种方式,通过互斥锁可以锁定一个代码块,被锁定的这个代码块,所有的的线程只能顺序执行,这样多线程访问共享资源的数据混乱的问题就可以被解决了,需要付出的代价就是执行效率降低。
在Linux中互斥锁类型为pthread_mutex_t,创建一个这种类型的变量就得到了一把互斥锁:
1 | pthread_mutex_t mutex; |
在创建的锁对象中保存了当前这把锁的状态信息,锁定还是打开,如果是锁定状态还记录了给这把锁枷锁的县城信息(线程ID),一个互斥锁变量只能被一个线程锁定,被锁定后其他的线程再对互斥锁变量加锁就会被阻塞,直到这把互斥锁被解锁,被阻塞的线程才能被解除阻塞。一般情况下,每一个共享资源都对应一把互斥锁,锁的个数和线程格式无关。
使用示例:
1 | //初始化互斥锁 |
- 参数:
- mutex:互斥锁变量的地址
- attr:互斥锁的属性,一般使用默认属性即可,这个参数指定为NULL;
- 返回值:成功为0,否则错误码
1 | //修改互斥锁的状态,将其设定为锁定状态,这个状态被写入到参数mutex中 |
这个函数被调用首先会判断参数mutex互斥锁中的状态是不是锁定状态:
- 没有被锁定,这个线程可以加锁陈工,这个锁中记录是哪个线程加锁成功了
- 如果被锁定,其他线程枷锁失败,这些线程都会阻塞在这把锁上
- 当这把锁被揭开后,这些阻塞在锁上的线程就会解除阻塞了,并且这些线程是通过竞争的方式对这把锁加锁的,没抢到的线程会继续阻塞。
1 | //尝试枷锁 |
调用这个函数对互斥锁变量加锁还是有两种情况:
- 如果锁没有被锁定,线程加锁是成功的
- 如果锁被锁住了,不会被阻塞,加锁失败直接返回错误号
1 | //对互斥锁解锁 |
示例:
1 |
|
死锁
当多个线程访问共享资源,需要加锁,如果锁使用不当就会造成死锁现象。如果线程死锁造成的后果是:所有的线程都被阻塞,并且线程的阻塞是无法解开的(因为可以解锁的线程也被阻塞的)。
造成死锁的场景有以下几种:
- 加锁后忘记解锁
- 重复加锁造成死锁
- 多个共享资源随意加锁导致相互阻塞
预防:
- 避免多次锁定
- 对共享资源上锁后一定要解锁,或者使用trylock
- 如果程序有多个锁,可以控制锁的访问顺序(顺序访问共享资源,但是在有些情况下是做不到的),另外也可以在对其他互斥锁加锁操作之前,先释放当前线程拥有的互斥锁。
- 项目程序中可以专门引入一些专门用于死锁检测的模块
读写锁
读写锁是互斥锁的升级版,在做读写操作时可以提高程序的执行效率,如果所有的线程都是读操作,那么读是并行的,但是使用互斥锁,读操作也是串行的。
读写锁是一把锁,锁的类型为 pthread_rwlock_t,有了类型之后就可以创建一把互斥锁了:
1 | pthread_rwlock_t rwlock; |
读写锁能锁读也能锁写,以下为其特点:
- 使用读写锁的读锁锁定了临界区,线程对临界区的访问时并行的,
读锁时共享的。 - 使用读写锁的写锁锁定了临界区,线程对临界区的访问时串行 的,
写锁是独占的。 - 使用读写锁分别对两个临界区加了读锁和写锁,两个线程要同时访问这两个临界区,访问写锁临界区的线程继续运行,访问读锁的临界区的线程阻塞,因为
写锁比读锁的优先级高
程序中所有的线程都对共享资源做写操作,使用读写锁没有优势,和互斥锁是一样的,如果说程序中所有的线程都对共享资源有写也有读操作,并且对共享资源读的操作越多,读写锁更有优势。
Linux提供的读写锁操作如下:
1 |
|
- 参数:
- rwlock:读写锁的地址,传出参数
- attr:读写锁属性,一般使用默认属性,指定为NULL;
1 | //在程序中对读写锁加锁,指定的是读操作 |
调用这个函数如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作,调用这个函数依然可以加锁成功,因为读锁是共享的;如果读写锁已经锁定了写操作,调用这个函数线程就会被阻塞
1 | //这个函数可以有效的避免死锁 |
调用这个函数如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作,调用这个函数依然可以加锁成功,因为读锁是共享的;如果读写锁已经锁定了写操作,调用这个函数线程就会失败,对应线程不会被阻塞,可以在程序中对函数返回值进行判断,添加加锁失败后的处理动作
1 | //在程序中对读写锁加锁,指定的是写操作 |
调用这个函数如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作或者是写操作这个函数线程就会被阻塞。
1 | //这个函数可以有效的避免死锁 |
调用这个函数,如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作或者锁定了写操作,调用这个函数加锁失败,但是线程不会阻塞,可以在程序中对函数返回值进行判断,添加加锁失败之后的处理动作。
1 | // 解锁, 不管锁定了读还是写都可用解锁 |
使用示例:
8个线程操作同一个全局变量,3个线程不定时写同一全局资源,5个线程不定时读同一全局资源。
1 |
|
条件变量
条件变量主要作用不是处理线程同步,而是进行线程的阻塞。如果在多线程程序中只是用条件变量无法实现线程的同步,必须要配合互斥锁来使用,虽然条件变量和互斥锁都能阻塞线程,但是二者的效果是不一样的,区别如下:
- 假设又A-Z26个线程,这26个线程共同访问同一把互斥锁,如果线程A加锁成功,那么其余B-Z线程访问互斥锁都阻塞,所有的线程只能顺序访问临界区
- 条件变量只有在满足指定的条件下才会阻塞线程,如果条件不满足,多个线程可以同时进入临界区,同时读写临界资源,这种抢矿下,还是会出现共享资源中的数据的混乱
一般情况下条件变量用于处理生产者和消费者模型,并且和互斥锁配合使用。条件变量类型对应的类型为pthread_cond_t,这样就可以定义一个条件变量类型的变量了:
1 | pthread_cond_t cond; |
被条件变量阻塞的线程的线程信息会被记录到这个变量中,以便在解除阻塞的时候使用。
1 |
|
- 参数:
- cond:条件变量的地址
- attr:条件变量的属性,一般使用默认属性,指定为NULL
1 | // 线程阻塞函数, 哪个线程调用这个函数, 哪个线程就会被阻塞 |
从函数原型可以看出,该函数在阻塞线程的时候,需要一个互斥锁参数,这个互斥锁主要功能就是进行线程同步,让线程顺序进入临界区,避免出现共享资源的数据混乱。该函数会对线程做以下几件事情:
- 在阻塞线程的时候,如果线程已经对互斥锁mutex上锁,那么会将这把锁打开,这样做为了避免死锁
- 当线程解除阻塞时,函数内部会帮助这个线程再次将这个mutex互斥锁锁上,继续向下访问临界区
1 | // 表示的时间是从1971.1.1到某个时间点的时间, 总长度使用秒/纳秒表示 |
这个函数的前两个参数和pthread_cond_wait函数是一样的,第三个参数表示线程阻塞的时长,但是需要额外注意一点:struct timespec这个结构体中记录的时间是从1971.1.1到某个时间点的时间,总长度使用秒/纳秒表示。
示例:
1 | time_t mytime = time(NULL); |
1 | //唤醒阻塞在条件变量上的线程,至少有一个被解除阻塞 |
调用上面两个函数中的任意一个,都可以唤醒被pthread_cond_wait或者pthread_cond_timewait阻塞的线程,区别就在于pthread_cond_singal是至少一个被阻塞的线程(总个数不定),pthread_cond_broadcast是唤醒所有被阻塞的线程。
信号量
信号量用在多线程多任务同步的,一个线程完成了某一个动作就通过信号量告诉别的线程,别的线程再进行某些动作。信号量不一定是锁定某一个资源,而是流程上的高年,比如:有A、B两个线程,B线程要等A线程完成某一任务后再进行自己下面的步骤,这个任务不一定是锁定某一资源,还可以是进行一些计算或者数据处理之类。
信号量(信号灯)于互斥锁和条件变量的主要不同在于灯的概念,灯亮意味着资源可用,灯灭意味着不可用。信号量主要阻塞线程,不能完全保证线程安全,如果要保证线程安全,需要信号量和互斥锁一起使用。
信号量和条件变量一样用于处理生产者和消费者模型,用于阻塞生产者线程或者消费者运行。信号的类型为sem_t对应头文件为<semaphore.h>:
1 |
|
- 参数:
- sem:信号量变量地址
- pshared:
- 0:线程同步
- 非0:进程同步
- value:初始化当前信号量拥有的资源数(>=0),如果资源数为0,线程就会阻塞了。
1 | //参数sem就是sem—init()的第一个参数 |
当线程调用这个函数,并且sem中的资源数>0,线程就不会被阻塞,线程会占用sem中的一个资源,因此资源数-1,直到sem中资源数减为0时,资源被耗尽,因此线程也就被阻塞了。
1 | //参数sem就是sem—init()的第一个参数 |
当线程调用这个函数,并且sem中的资源数>0,线程就不会被阻塞,线程会占用sem中的一个资源,因此资源数-1,直到sem中资源数减为0时,资源被耗尽,但是线程不会被阻塞,直接返回错误号,因此可以在程序中添加判断分支,用于处理获取资源失败之后的情况。
1 | // 表示的时间是从1971.1.1到某个时间点的时间, 总长度使用秒/纳秒表示 |
该函数参数abs_timeout和pthread_cond_timedwait最后一个参数是一样的。当线程调用这个函数,并且sem中的资源数>0,线程不会阻塞,线程会占用sem中的一个资源,因此资源数-1,直到sem中的资源数为0时,资源被耗尽,线程被阻塞,当阻塞指定时长之后,线程解除阻塞。
1 | // 调用该函数给sem中的资源数+1 |
调用该函数会将sem中的资源数+1,如果有线程在调用sem_wait、sem_trywait、sem_timedwait时因为sem中的资源数为0被阻塞了,这时这些线程会解除阻塞,获取到资源之后继续向下运行。
1 | int sem_getvalue(semt_t *sem,int sval); |
通过这个函数可以查看sem中现在拥有的资源个数,通过第二个参数sval将数据传出,也就是说第二个参数的作用和返回值是一样的。
本篇学习来源:
来源: 爱编程的大丙( 博客:https://subingwen.cn,
哔哩哔哩:【多线程和线程同步-C/C++】https://www.bilibili.com/video/BV1sv41177e4?p=8&vd_source=602097138258a0057a732e44579de1ed)
