菜鸟笔记
提升您的技术认知

Go 多线程

线程编程

​ 一个进程可以包含多个线程,这些线程运行的一定是同一个程序(进程==程序),且都由当前进程中已经存在的线程通过系统调用的方式创建出来。进程是资源分配的基本单位,线程是调度运行的基本单位,线程不可独立于进程存在。

​ 所有线程都有自己的线程栈,以此存放自己的私有数据(包含在进程的虚拟内存地址中)。一个进程中的很多资源也会被线程所共享,包括在当前进程虚拟内存地址中存放的代码段、数据段、堆、信号处理函数、文件描述符(非负整数)。正因如此,创建一个线程时不会像创建一个进程时那样耗费资源,因为其大多数资源都因共享而无需被复制。

(一)线程简介

一、线程状态

​ 与进程这种家族式的树状结构不同,线程之间的关系都是平等的。他们可以互相进行如下四种操作。

操作 作用
pthread_create 创建线程——除主线程随着进程一同创建,其他线程都通过线程传入代码段与参数来创建,并会返回TID。
pthread_cancel(TID) 终止线程——取消给定TID代表的线程,目标线程总是会接受该请求并在某取消点响应该请求。
pthread_join(TID) 连接已终止线程——一直等待到该TID代表的线程终止,并将其返回值告知调用线程。
pthread_detach(TID) 分离线程——将目标线程变为不可被连接。内核会在其终止时自动进行清理和销毁工作。

注意:

(1)线程默认都是一个可连接线程。如果在其终止时未被连接,则会变成一个僵尸线程,当有其他进程连接该僵尸进程,则会被回收。

(2)分离操作是不可逆的操作。

(3)创建新城需要调用系统调用并给定执行函数和参数,线程可以有返回值。

(4)return、系统调用exit会终止当前、所有线程。显示调用pthread_exit也会终止线程。二者的区别是主线程调用前者会终止进程内所有线程,后者只会终止主线程。

二、线程调度

​ 线程的执行总是趋向于CPU受限或IO受限。而调度器会为IO受限的线程提供更高的动态优先级。调度器会尽量使一个线程在一个特定的CPU上运行,这样可以提高cache命中率以及高效使用内存,当一个CPU过于繁忙时,调度器也将线程迁移至空闲CPU运行。

名称 说明 作用
动态优先级 可以被调度器实时调整(在静态优先级的基础上得到) 决定线程的运行顺序
静态优先级 只可以由应用程序指定,默认为0 决定CPU时间片的大小

​ 线程会按照动态优先级的大小排列在激活的优先级阵列中。当一个线程已经占用了较长的CPU时间(T<=时间片大小),那么该线程就会被排入过期的优先级阵列,在其后的线程则会被放在CPU上运行。当激活的阵列已经没有等待线程时,会将激活阵列与过期阵列进行身份互换,继续运行新激活阵列上的线程。

​ 线程会因为等待某个事件或条件的发生而加入到对应的等待队列中,并随即进入睡眠状态。当事件发生时,内核会通知对应的等待队列中的所有线程,这些线程会被唤醒并被转移到适当的运行队列中。

状态名 描述
可中断的睡眠状态 睡眠直到某个条件变为真,如产生一个硬件中断、释放正在等待的系统资源或是传递一个信号。
不可中断的睡眠状态 只能被如硬件中断、正在等待的系统资源被释放等唤醒,对其他进程传递的信号不响应。

三、线程实现模型
  • 用户级线程模型

​ 线程由用户级别的线程库全程管理。这些线程存储在进程的用户空间,内核无法感知。线程的创建、终止、切换全部在用户态下完成。然而由于线程无法被内核调度,所以该进程被调度器视为一个无法分割的整体单元,无法实现多线程并发以及CPU负载均衡。它实现的实际上是一个多用户级线程对应一个内核调度实体(M:1)。

  • 内核级线程模型——Linux

​ 线程由内核负责管理。应用程序对线程的创建、终止、同步都必须通过内核提供的系统调用完成,所以操作系统无需在线程库级别管理线程。然而内核创建大量调度实体与线程对应,会用到更多的内核资源,同时内核的线程管理成本要比用户级线程管理高很多,线程创建、切换、同步耗费的时间也更高,给内核调度器带来巨大负担。它实现的实际上是每一个用户级线程对应一个内核调度实体(1:1)。

  • 两级线程模型——Go

​ 线程由内核与线程库共同管理。一个进程可以关联多个内核调度实体,而进程中的线程却不与其一一对应,这些线程可以映射到同一个已关联的内核调度实体上。即先通过操作系统内核创建多个内核级线程,通过这些内核级线程对用户级线程进行调度。它实现的实际上是多个用户级线程对应多个内核调度实体(M:N)。Go中称用户级线程为goroutine

四、线程同步

​ 由于一个进程所拥有的相当一部分虚拟内存地址都可以被该进程中的所有线程共享,为保障共享数据的一致性,引入临界区概念。即只能被串行化访问或执行的某个资源或代码段。通过采用互斥量、原子操作这类同步工具保证临界区有效。

  1. 互斥量

​ 在同一时刻,只允许一个线程出于临界区内的约束称为互斥。每个线程在进入临界区之前,都必须先锁定某个对象,只有成功锁定该对象的线程才可以进入临界区,否则阻塞。这个对象就称为互斥量

​ 互斥量分为初始化—(未锁定状态)—锁定—(已锁定状态)—解锁—(未锁定状态)。每个互斥量保护的临界区应该在合理范围内并且尽可能的大,但如果多个线程频繁出入较大临界区并且发生冲突,则应考虑切分该临界区并使用不同互斥量加以保护。注意在不同互斥量保护的临界区不应该包含对同一个资源的同种(不同)操作。这将导致多个进程可以通过不同的互斥量进入同一临界区。

​ 互斥量的出现导致的唯一问题就是死锁。一般通过“试锁定-回退”或者“固定顺序锁定”解决。前者在锁定多个锁失败时,会解锁之前的互斥量,并重新尝试加锁;后者规定所有线程上锁的顺序,永远是锁定了1才可以锁定2。

  1. 条件变量

​ 条件变量与互斥量组合使用。当对应的共享数据状态发生变化时,通知其他因此而被阻塞的进程。

操作 作用
等待通知(wait) 阻塞当前线程,直至收到该条件变量发来的通知
单发通知(signal) 该条件变量向至少一个正在等待它通知的线程发送通知
广播通知(broadcast) 该条件变量给正在等待它通知的所有线程发送通知

注意:(1)等待通知操作在临界区内进行,即线程获取到互斥量后,发现临界区的值不符合条件,所以解锁互斥量并阻塞该线程。

​ (2)等待通知(解锁互斥量,阻塞当前线程)是个原子操作。阻塞之后该线程会一直等待条件变量通知,并尝试再次锁定。

五、线程安全

​ 线程安全:一个代码块,可以被多个线程并发执行,并且总能达到预期效果,则认为是线程安全。

​ 可重入函数:一个函数,如果多个线程并发的调用的结果,和它们以任意顺序依次调用的结果总是相同的,则认为是可重入函数。如果一个函数把共享数据作为它返回的结果或者包含在它返回的结果中,它一定不是一个可重入函数。

​ 程序性能指标:响应时间和吞吐量。

​ 程序的正确性和可伸缩性:后者是指增加CPU核心数的情况下,其运行速度不会受到负面影响(在多CPU并行的条件下,实现互斥量之类的串行操作,需要内核、CPU共同协调,CPU核心越多,协调工作越复杂)。如何在平衡二者的关系?

  • 控制临界区纯度:临界区代码只包含操作共享数据的代码
  • 控制临界区粒度:粒度过细会增加底层协调工作的次数,所以将几段操作同一共享数据的临界区合并。
  • 减少临界区代码执行耗时:对一个包含操作不同共享数据的临界区,应将其分为多个临界区;同时改进算法。
  • 避免长时间持有互斥量:在临界区中代码会等待某个共享数据的情况,引入条件变量来适时对该互斥量进行解锁与锁定。
  • 优先使用原子操作而不是互斥量