线程初探

总体介绍

线程(Thread),有的时候也被称为轻量级进程(Lightweight Process,LWP),是程序执行流中的最小单元,这个我们看到线程被冠以了xxx进程的名字,先卖个关子,在介绍linux中的线程性质的时候我们就可以清楚的体会到这个名字的内涵了。再说回来,一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组成。通常我们直接观察一个进程的时候,可以看到这样的视图:一个进程由一个到多个线程组成,各个线程之前共享程序的内存空间(包括代码段,数据段,堆等)及一些进程级的资源(如打开文件和信号)。

为啥使用线程?

  • 多线程可以有效的利用等待时间。一个典型的场景就是我们做的操作陷入了比较长的等待时间,等待的线程就会进入waiting状态,无法继续执行。但是若有多线程,则可以执行Ready状态的线程。
  • 交互与计算分开。这个也很容易理解,毕竟我们总不希望用户交互在计算负载比较高的时候陷入停顿,所以可以使用两个线程,一个计算一个交互。
  • 并发
  • 共享数据,直观的说,同一个进程的不同线程之间共享数据的方式,还是比消息传递实现其实容易的(但是优化上就不一定了)

线程的访问权限

c语言上来讲,有下表:

线程私有 线程之间共享(进程所有)
局部变量 全局变量
函数的参数 堆上的数据
TLS数据 函数中的静态变量
程序代码
打开的文件

其中TLS表示的是,线程局部存储,线程局部存储是某些操作系统为线程单独提供的私有空间,但是容量就比较少了。

线程调度与优先级

先说一个比较合乎直观的问题。我们日常使用多线程的时候,其实不会把线程池搞得很大。在一定的硬件环境下,若线程很多的时候,系统的性能不会很好。这是由于,无论是在单处理器还是多处理器的计算机上,线程总是并发的,也就是当线程小于等于处理器的数量时(明确前提,操作系统支持多处理器),线程的并发是真实的,也就是不同的线程实际上运行在不同的处理器上,占有了全部(全部可以占有的)硬件资源。但是对于线程数量大于处理器数量的情况,线程的并发会遇到问题,因为这个时候一个处理器会处理不止一个线程。这时就需要引入线程调度机制(Thread Schedule)了。
Thread Schedule中线程至少有三个状态:

Running Ready Waiting
线程正在运行 线程这个时候可以立即执行,但是CPU已经被占用 线程正在等待某事件的返回,无法继续执行

Thread Schedule的重要组成部分有优先级调度和轮转法。先说轮转法,其决定了线程交错执行的特点,也就是轮流的让每个线程都执行一段时间;线程的优先级调度则是决定了线程按照什么顺序轮流执行。windows中可以使用winapi执行线程的优先级,而到了linux中我们可以使用pthread库实现(注意这个要是renice区分开)。

在实际的运行中,I/O密集型线程可以比CPU密集型线程更快的获得优先级的提升。毕竟CPU也是喜欢捏软柿子,CPU密集型的操作往往会占用整个时间片,让CPU不堪重负。但是这样的调度机制其实也是不完善的,没有广泛的考虑到劳苦大众,也就是优先级很低的线程的调度。若有线程的优先级很低,总有优先级比它高的线程要优先执行,这个线程很有可能长时间得不到执行。所以有了Starvation机制。这个机制很像dota2中计算暴击率的方法,我们日常使用PA(幻影刺客)这样的靠脸的英雄时,常常会遇到就是不暴击的情况,轻则导致自己被反杀,重则导致团灭。所以为了广大靠脸吃饭用户的体验,value将dota2中的暴击概率搞成一定攻击次数不暴击,暴击概率动态上升。这里实际上也是同样的想法。也就是说,一个线程等的时候足够久,其优先级一定可以提升到可以让其执行。

总的来讲,优先级的提升一般有三种方式:

  • 用户指定优先级
  • 根据进入等待状态的频繁程度提升或降低优先级
  • 长时间得不到执行而被提升优先级

可抢占线程和不可抢占线程

之前所讲的线程调度有一个特点,就是线程在用尽时间片之后会强制剥夺继续执行的权利,而进入就绪状态,这个过程叫抢占。简而言之,就是线程之间抢CPU资源的过程。但是实际上在早期的win系统中,线程是不可抢占的,也就是说,线程必须要足够圣贤,自己将自己的执行权限放弃,才可以让其他的线程执行。在这样的机制下,线程需要自己进入Ready状态,而不是线程的时间片用尽之后强制进入。总结下,线程主动放弃执行有两种可能:

  • 当线程试图等待某事件时
  • 线程主动放弃时间片

这有个好处,就是线程调度的时机是确定的,但是线程的调度权限有点大,所以在现在应用中,广泛使用的还是不可抢断式线程。

linux多线程

在多线程这个东西上,其实我们linux党很多程度上还是被win党鄙视的。windows对于线程和进程的实现简直和其本身的抽象定义是一摸一样的(可以参考CSAPP中的定义)。 windows中也可以使用明确的API:CreateProcess和CreateThread来创建进程和线程,但是对于linux来讲,线程实际上不是一个通用的概念。

linux内核中并不存在真正意义上的线程概念。linux将所有的执行实体(这个实体包含了进程和线程)都称为task,这个task可以理解为一个只内含单线程的进程,具有内存空间,执行实体,文件资源等。但是linux下不同的task之间可以共享内存空间,其实也就是实现了线程的重要概念。

在linux下创建线程,我们可以使用:

系统调用 作用
fork 复制当前进程
exec 使用新的可执行映像覆盖当前的可执行映像
clone 创建子进程并从特定位置开始执行

以上的系统调用,我们可以在bash中执行一个可执行文件,并且通过strace追踪感受下。

其中fork产生新task的速度非常快,因为fork使用了COW机制,也就是两个task可以同时自由读,但是任意一个内存需要修改内存的时候,内存会提供一份复制单独给其使用,听起来是不是有点像高速缓存一致性的MESI协议?但是fork也是有局限的,它只能产生本任务的镜像,因此需要使用exec配合才可以启动新的项目。exec可以使用新的可执行映像替换当前的映像。总结来讲。fork+exec---->new task, clone -----> new thread。

线程安全

多线程的环境实际上还是非常复杂的,考虑到之前介绍的线程可访问对象,我们需要保证并发时数据的一致性。

竞争和原子操作

很多时候,在多线程的环境下(如openmp),我们使用自增操作的时候需要考虑原子性。这里只是举了自增操作这个例子,实际上,只要是一个操作被编译为汇编代码之后不止一条指令,在执行这个操作的指令流的时候就可能会被其他的进程打断,导致最后结果出现错误。所以我们需要atmoic操作,保证其实现是原子的。其实我这里很想说,atmoic应该改成quantum比较合适。但是实际上原子操作的操作类型和应用场景还是远远不够的,我们需要更多的实现。

同步与锁

这个为了避免多个线程同时对于同一个数据访问导致不可预料的结果,所以需要对各个线程的访问进行同步设计。其中常用的方式是锁。锁是一个非强制机制,每一个线程在访问数据或资源的时候,都需要先获取锁,并在访问结束之后释放锁。在锁已经被占用的时候,线程会等待,直到锁可用。讲到这里,我们不妨回头想想之前所提到的线程切换方式,不看看出,其实就是通过锁实现的。一个典型的例子就是Cpython中为了线程安全引入了全局解释器锁(GIL
)。

此外实际的使用中,我们也会使用如信号量,互斥量,读写锁,条件变量(类似于openmp设置栅栏)。其实在了解这些概念的时候,我发现他们和并行多核的体系结构有很大的关系,其中很多这样的面向软件或者说比较抽象的概念实际上都可以在并行机器的硬件实现中找到影子,由于是多核处理器的缓存一致性协议,很多时候也渗透了同步和类似事务一致性的思想,不可谓不奇妙,让人回味无穷,哈哈哈。注意,这里还仅仅是线程这一块的概念,没有消息传递过程,所以对应的应该是MESI协议状态机中一个单缓存块的状态转换,而不包含其对总线的响应过程。

过度优化/编译器带来的问题

但是有了我们上面提到的锁,对于线程安全就是完备的解决方案了吗?实际上不是这样的。我们面临了编译器的问题。
很多人对于c或java中的volatile参数应该是不陌生的,实际上这个参数就是为了防止在多线程的环境下,编译器/java虚拟机过度优化,使得寄存器/缓存(这里假设缓存是写回的,而不是写直达的)不直接将变量写回内存造成非一致性,结果引起多线程错误。另外volatile也可以使得被其修饰的变量不交换指令顺序。但是对于另外的执行顺序就不一定了,还需要显式的设置barrier。这里,有人可能会说了,使用写直达的缓存方式不是可以解决这个问题吗?但是写直达只是能解决前面的写回一致性的问题,而且其对总线的占用很大,延迟高,我们现代的CPU基本上都是写回式的,而且不能像安腾处理器那样指定缓存的状态,所以还是接受这个现实,多多留心。

用户态线程与内核态的对应

程序虽然是我们写的,但是多线程的真正调度实际上是操作系统来实现的。那我们的用户态多线程该如何和内核态中对应呢?简单来讲有三种:

一对一模型

最简单的实现方式,一个用户态线程对应一个内核态线程。线程之间的并发是真正的并发,一个线程阻塞的时候,其他线程不会受到影响。在linux中,直接使用系统调用,clone创建的线程一般就是一对一线程。但是一对一线程也有缺点,首先许多os限制了内核线程的数量,使用一对一模式的时候,用户的线程数量会受到限制;另外os在进行系统内核调用的时候,频繁上下文的开销很大,这样会导致用户线程执行效率下降。

多对一模型

多对一模型中,多个用户线程映射到一个内核线程中,线程之间的切换由用户态的代码来进行,因此其线程切换的速度是很快的。但是有一个很致命的问题,就是一个线程阻塞的时候,内核中的线程也会阻塞,其他映射到这个内核线程的用户态线程也就无法执行了。

多对多模型

折衷,这个应该是计算机架构中出现做多的词之一了,应该就不用解释了。

最近在学习的物理模式中做了一个并发I/O的module,简单的总结下学习的知识。

猜你喜欢

转载自www.cnblogs.com/gabriel-sun/p/12158058.html