今天我们来讲一下多线程。
1.Linux 线程概念
1.1 背景引入
相信大家如果学习过Linux下的进程的话,下面这种图大家都很熟悉了:
现在我们不在重复进程的相关结构,我们假设一个这样的场景:
如果我们今天创建进程,不独立创建地址空间,用户级页表,甚至不进行IO将程序数据和代码加载到内存。
我们只创建 task_struct ,然后让新的PCB指向和老的PCB同样的mm_struct(虚拟内存),然后通过合理的资源分配(当前进程的资源,比如将代码区划分为多份给多个PCB去执行),来执行这个进程。
这样的操作,是的每一个 task_struct 都可以使用进程的一部分资源。此时我们的每一个PCB被CPU调度的时候,执行的“粒度”都要比原始进程执行的“粒度”更小一点。
1.2 线程的概念
线程本质是在进程的地址空间内运行的一个执行流。
1.3 重新理解进程
我们将橙色区域统一称为 Linux进程.
由于线程这一概念的引入,我们对进程的理解也有了一些变化:
站在OS的角度,进程是承担分配系统资源的基本单位。一个进程被创建之后,后续可能会存在多个执行流(线程).
那么又如何看待我们之前学习的,使用的进程? 其本质是承担系统资源的实体,不过内部只存在一个执行流。
1.4 Linux 线程 vs 其他平台下的线程
站在CPU的角度,不存在任何区别。不管是我们过去所理解的进程,还是现在的进程,在CPU看来都是与PCB打交道。但是CPU执行的时候,可能执行的“进程流”已经比执行之前更加轻量化了。
但是,其实在Linux下,是没有真正意义上的线程的概念的,而是使用进程来模拟的。此时我们将这类“进程”叫做“轻量级进程”。
但是,Windows中是具有真正的线程概念的。
系统内可能存在大量的进程,进程:线程=1:n ,那么系统中一定存在着大量的线程。这也大致OS要进程线程的管理(先描述,再组织) 。所以,支持真线程的系统是一定要做到描述线程的TCB(thread ctrl block)。
此时这样的系统既需要线程管理(TCB),又需要进程管理(PCB),这样的设计会导致高复杂性,低可依赖性,所以从这方面考虑,Linux 的设计优于Windows.
1.5 进程 vs 线程
进程是承担分配系统资源的基本实体,线程是OS调度的基本单位。
同一进程的线程共享 同一地址空间吗,因此 TextSegment ,DataSegment都是可以共享的,如果定义一个函数,在各个线程中都可以调用,如果定义一个全局变量,在各个线程中都可以访问,除此之外,各线程中都可以访问到。除此以外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式
- 当前的工作目录
- 用户id 和组id
不过,线程也还是拥有自己的一部分数据的:
- 线程id
- 上下文数据
- 栈(每一个线程都有自己的栈结构)
- errno(错误码)
- 信号屏蔽字
- 调度优先级
1.6 线程的操作
对于线程的操作,由于Linux 不存在真正的线程,所以也不可能直接在OS层面上提供系统调用接口,最多是轻量级进程的调度接口。
我们一般使用原始线程库,这是Linux 在应用层封装的一套对外接口。
1.7 线程的优缺点
1.7.1 线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务(eg:迅雷下载的同时可以观看)
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
1.7.2 线程的缺点
因为所有的PCB都是共享地址空间,理论上,每一个“线程”都可以看到进程的所有资源。这样的好处是线程间通信的成本很低,但是缺点也很明显:一定存在大量的临界资源,这也势必可能需要使用各种互斥和同步机制来保证资源安全。
-
性能损失 。线程并不是越多越好,而是合适最好。过多的线程会导致花费大量成本进行切换。一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型
线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的
同步和调度开销,而可用的资源不变。 -
健壮性降低。编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
-
缺乏访问控制。进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响
-
编程难度提高。编写与调试一个多线程程序比单线程程序困难得多
1.8 线程异常
线程 是进程的一个执行分支,野指针,除0等异常操作导致线程退出的同时,也意味着进程发生了该错误,进程也会随之崩溃退出。
2. 线程控制
2.1 POSIX 线程库
POSIX 线程库是系统提供的基于应用层的一套线程库。
- 与线程相关的函数构成了一个完整的序列,绝大多数的函数名称都是以"pthread_"打头的
- 要是用这些库函数,要引入头文件<pthread.h>
- 链接这些线程库函数时,我们要添加编译器命令的 "-lpthread"选项
2.2 创建线程
功能:创建一个线程
函数原型:
int pthread_create(pthread_t *thread,const pthread_attr_t*attr,
void* (*start_rountine)(void*),void*arg);
参数:
- thread : 输出型参数,返回线程id
- attr: 设置线程的属性,attr为NULL表示使用默认属性
- start_routine:是个函数地址(函数指针),线程启动后要执行的函数(你想让线程执行的操作)
- 传给线程启动函数的参数
返回值: 成功返回0,失败返回错误码
我们写一段代码:
阅读上面的代码,我们存在两个线程,一个在主函数之中,一个在thread_run之中。
我们使用 ps 命令来查看进程,发现只有一个进程在运行:
我们使用 ps - aL 命令(L 表示 light,即轻量级进程(即线程)) 来查看线程,发现有两个线程存在:
进一步观察,我们发现他们俩的PID是相同的,说明mythread本质是属于同一个进程的。而LWP是不同的,表示执行流是一个轻量级进程,标识唯一性。
所以CPU在调度多执行流进程的时候,是依据LWP来区分各个执行流。
这时候有同学就会有疑问了,那么我们代码中的tid是啥?又有什么用途?
我们还是来写一个程序看一下:
观察tid的值以及其地址,我们可以推测出tid有可能是一种地址数据,之后我们再进一步介绍。
简单来说,只需要知道tid其实是应用层中该库管理线程的标识符, 而LWP是OS 底层用来管理线程的标识符。
2.3 进程退出
如果需要只终止某个线程而不是进程,可以有三个看法:
- 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
- 线程可以调用pthread_ exit终止自己。
- 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程
pthread_exit函数
功能:线程终止
原型:
void pthread_exit (void *value_ptr);
参数:value_ptr:value_ptr不要指向一个局部变量
2.4 获取进程id
pthread_self()
使用很简单,我们在那个线程下使用,调用该函数,就可以得到其线程id.
2.5 线程取消
pthread_cancel()
功能:取消一个执行中的线程
原型:
int pthread_cancel(pthread_t thread);
参数:
thread: 线程id
返回值: 成功返回0,失败返回错误码
常规情况下我们不建议在子线程中取消主线程。
2.6 线程等待
线程在终止之后,一般要进行等待,主线程如果不等待,会造成和进程退出类似的效果(僵尸进程)
pthread_join()
功能: 等待线程结束
原型:
int pthread_join(pthread_t thread,void **value_ptr);
参数:
- thread:线程id
- value_ptr:它指向一个指针,后者指向线程的返回值
返回值: 成功返回0,失败返回错误码
为什么需要线程等待?
- 防止空间泄漏
- 保证主线最后退出,让新线程正常结束
- 我们需要通过pthread_join 来获得线程退出时的退出码结构
我们可以写一个程序来测试一下:
对于进程来说,我们可以通过 退出码 来知道 该进程把一个任务完成的如何,如果 退出码为0,那就表示成功,如果是非0,就代表失败。
那么对于线程来说呢?也是类似的,但是线程接口更加灵活,返回类型是 void*类型,也就是说,我们可以返回任何自定义类型作为返回值。
但是,想要接受这个值并不是拿一个同类型的变量去接收,而是用pthread_join中的第二个(输出型)参数去接受
我们不但可以退出 数值,还可以退出结构体等自定义类型,比如下列代码:
我们在上面讨论的都是正常的情况,如果发生异常,又存在哪些情形呢?
已知进程退出有几种可能:
- 代码跑完,结果正确
- 代码跑完,结果错误
- 代码没跑完,程序崩溃
那么我们的主线程在进行进程等待的时候,需不需要考虑线程崩溃的问题? 并不需要,因所有线程是一个“命运共同体”,线程出现错误直接导致进程退出,所以最后还是依靠父进程通过退出码/信号 来判断进程的退出原因。
我们再来进行一个测试,我们启动五个子线程,然后立即取消三个线程,观察会发生什么:
这里有几个细节要强调一下:
- 建议一定让我们的线程完全跑起来之后,再进行其他操作。有可能存在新线程被创建了,但是还没有被调度,这也是我们取消之前要sleep(1).。 也可以说,cancel具有一定的延迟性,可能并不是被立即受理的,再线程中取消最好。
- 如果线程是被取消的,那其退出码被设置为-1
- 调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通pthread_join得到的.终止状态是不同的,总结如下:
- 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
- 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数
PTHREAD_ CANCELED。 - 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参
数。 - 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。
2.7 在线程中进行程序替换
我们是不建议在进程中进行程序替换的,因为程序替换对应的是 进程级别,我们在一个线程中替换了代码,那么由于各个线程共享进程空间,一边则都变。可能造成不好的结果。
2.8 线程分离
默认的情况下,新创建的线程是joinbale,线程退出后之后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄露。
但是不关心线程的返回值,join是一种,这个时候,我们可以告诉系统,当线程退出的时候,自动释放线程资源。
分离的本质,是让主线程不用再join 新线程,从而可以让新线程退出的时候,自动回收资源!
pthread_detach(pthread_t thread)
眼见为实,我们写一个程序:
我们在一个新线程中把自己分离,但是依然在主线程中去等待它,观察结果:
我们发现,我们的join返回值是22,也就是说 是等待失败了的。
如果一个线程被设置为分离状态,该线程不应该被join,结果是未定义的,一定会join出错,join的出错,也导致了return 0直接执行,进程被释放,剩下的4个线程无法执行。
更加稳妥的方式是在主线程中完成子线程的分离(不用再先sleep了),但是,无论何时都不用再等待了。
但是,即便线程被设置为分离状态,但是如果该线程出错奔溃(除零,野指针…),还是会影响主线程和其他的正常线程。
2.9 进一步了解线程库NPTL
a. 原生线程库是一个库
我们再把共享区放大来看:
所谓的动态库 就是pthread库,在这其中存在许多的小的数据段用来维护 线程在用户级别下的相关数据(比如线程PCB,上下文数据,线程栈等),其中所谓的tid 是每一个效数据段的起始地址,帮助我们找到每一个线程。
但是值得一提的是: 主线程 是不使用库中的栈结构的,直接使用地址空间中的栈。所以主线程可以被认为是“纯正的”进程。
对于我们创建每一个 用户及线程,在底层都会对应一个(或多个)LWP(内核级线程),真正执行操作的使内核级操作。
我们可以把内核空间理解为 黑社会,LWP是卧底,用户级线程是警察,他们是一对一对接,警察可以要求卧底去完成任务,并查看是否完成。
3. Linux线程互斥
3.1 问题引入
我们按照之前学习的的多线程,写一个简单的抢票程序:
假设公有2000张票,我们建五个线程去抢票,平打印票数的变化过程。
我们惊奇的发现,最后票数居然变成了一个负数。
问题在于 当多线程对一个全局变量(临界资源),进行一一操作的时候,是否是原子的呢?显然并不是。
我们可以想一下,CPU计算 ticket–这个语句,需要有几步?
- ticket 从内存转移到CPU的先关寄存器中
- CPU内存,与要对ticket++
- 将递增完毕的ticket值写会内存
如果一个操作是由大于1句构成的,绝对不是原子的。
如果我们现在有线程A和B:
A线程先运行,但是在运行完第2步之后时间片到了,线程A先关数据被剥离CPU,上下文数据暂时存储在线程A的PCB中,也就是 999作为一个临时量存储起来,等待下次时间片到来时执行第3步。线程A剥离,线程B开始执行,在一个时间片的时间内,线程B完整的执行了10次 ticket–的操作,此时内存中的ticket值为990,此时线程B剥离,线程A执行,A将上下文数据载入CPU,cpu执行第三部将999写会内存,此时 ticket由 990 变成了 999 ,反而增加了。
所以,在多线程切换的情况下,极有可能出现因为数据交叉操作,而导致的数据不一致问题。
3.2 互斥量
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。
要解决以上问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量
3.3 互斥量的接口
3.3.1 初始化互斥量
初始化互斥量有两种方法:
- 方法一: 静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
- 方法二:动态分配
原型:
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t*attr);
参数:
- mutex: 要初始化的互斥量
- attr: NULL
3.3.2 销毁互斥量
声明:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
销毁互斥量需要注意:
- 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
3.3.3 互斥量加锁与解锁
声明:
int pthread_mutex_lock(pthread_mutex_t *mutex); //加锁
int pthread_mutex_unlock(pthread_mutex_t *mutex); //解锁
返回值: 成功返回0,失败返回错误码
这里我们要注意:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 发起函数调用的时候:
- 其他线程已经锁定互斥量,
- 存在其他线程同时申请互斥量,但没有竞争到互斥量
那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁
由此,我们可以完善我们的抢票程序,不过相应的,运行速度也会变慢:
这里我们需要强调几点:
- lock需要被所有的线程都看到,所以本质上 互斥量(锁)也是一种 临界资源。但是由于 lock,unlock被保证为原子操作,所以不会有任何问题。
补充:为了了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期
补充: exchange操作是原子的是硬件级别的“加锁”,其本身可能还是多条的(这一点我也不太清楚)
2.加锁不会使线程对应时间片延长,线程依旧随时会被切走。虽然我们随时可以被切走,但是我们是拿着唯一的一把锁走的,也就是,虽然我走了,但是别人也无可奈何,只能对着“数据”干瞪眼,最后时间到了,我回来了,我就继续执行,知道循环反复我把事情做完,再把锁释放。而且,任何人在我不在的时候,是不可能申请到锁的。
对于其他的线程来说,用有锁的线程执行自己的临界区命令的时候,要么不执行要么执行完毕。这也间接的实现了 原子性。
3.4 可重入VS线程安全
3.4.1 概念引入
-
线程安全:多个线程并发同一段代码的时候,不会出现不同的结果时,叫做线程安全。常见对全局变量或者静态变量进行操作,并且在缺少锁的保护的情况之下,是线程不安全的。
-
重入: 同一个函数被不同的执行流调用,当前一个流程还没有被执行完,就有其他的执行流再次进入,我们将之称作为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者问题,则该函数被称为可重入函数,否则,是一个不可重入函数。
3.4.2 常见的线程不安全情况
- 不保护共享变量的函数
- 函数状态随着被调用吗,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
3.4.3 常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作(c++ stl容器不是线程安全的)
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
3.4.4 常见的不可重入的情况
- 调用了 malloc/free函数 (malloc函数是用全局链表来管理堆的)
- 调用了标准IO库函数,标准I/O库的很对实现都以不重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
3.4.5 常见的可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
3.4.6 可重入与线程安全的联系与差别
- 联系
- 函数是可重入的,那么就是线程安全的。否则就不可以由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
- 差别
- 可重入函数是线程安全函数的一种。也就是说,线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生
死锁,因此是不可重入的。
3.5 常见锁概念
3.5.1 死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
死锁的四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用(即线程安全的前提下)
- 请求与保持条件: 一个执行流因请求资源二阻塞时,对已获得的资源不放
- 不剥夺条件:一个执行流已经获取的资源,在未使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁
- 破坏死锁的四个必要条件(属于是废话文学了)
- 加锁的顺序一致
- 避免锁没有释放的场景
- 资源一次性分配
避免死锁的算法(了解)
- 死锁检测算法
- 银行家算法
4.Linux 线程同步
4.1 条件变量
条件变量(cond)是一个由线程库提供的描述临界资源状态的对象的变量。 通过条件变量,不需要再频繁的通过申请或者释放锁的方式,也能够达到检测临界资源的目的。
为什么我们需要条件变量呢? 如果没有条件变量,我们的线程由于不知道资源的情况,只能不断通过轮询访问的方式不断的去申请,检测。
打个比方:你十分的饥饿,点了外卖,但是不知道外卖的状态,你就每隔20秒就打一次商家和骑手的电话,询问外卖的状态,这就是所谓的“轮询”,这显然不是一种明智的手段。
4.2 同步概念与竞态条件
- 同步: 在保证数据安全的前提之下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题。
- 竞态条件: 因为时序问题,而导致程序异常,我们称之为竞态条件。
所谓的饥饿问题,通俗来说,就是存在一个几个竞争能力特别的强的线程,每一次申请,都是它申请到锁,该线程一直进行申请,检测,释放锁,导致其他线程没有机会用到锁,这导致线程的“饥饿”。
4.3 条件变量的接口
4.3.1 条件变量的初始化
4.3.2 条件变量的销毁
4.3.3 唤醒等待
作用:唤醒在指定条件变量下等待的一个或多个线程
函数原型:
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
其中 pthread_cond_signal 是唤醒一个等待中线程,pthread_cond_broadcast类似与一个广播,唤醒一个等待队列中的所有的等待线程。
4.3.4 进行等待
作用: 让线程在指定的条件下进行等待 (直白点就是,没人叫你就先去等着)
4.3.5 简单的实际运用
我们通过上面的接口可以实现用一个线程来控制另一个线程。
代码如下:我们让线程2来控制线程1,线程2每隔一秒唤醒一次线程
实验结果:线程1每隔一秒打印一次 (虽然图上看不出来…)
我们也可以验证之前所说的 cond 下的等待队列:
可以发现,thread1,2,3 这三个线程按照顺序被激活,也就是按找顺序来访问临界资源:
这就是我们多线程的第一部分的内容。