开篇
1、背景
之前的很长一段时间里,随着加工工艺的发展,cpu的处理速度一直在提升,基本上每18个月就会翻倍。直到04年cpu主频达到了4.0GH以来,这种规律似乎已经失效,原因是人们在制造cpu的工艺方面已经达到了物理极限。除非技术有本质突破,才能进一步提高cpu的处理速度。然而需要处理的数据量并没有因此而停止增长,其中的一个方法就是采用多核、并行处理技术。这会成为并且正在成为未来发展的趋势。要理解并行技术,对线程有一定的了解是很必要的。这篇博客主要说一下自己对线程的看法,这只是从简单的角度来看问题,入门级文章,笔者认知有限,有不足之处还望不吝指正。
2、我的想法
关于并发编程,我觉得如果能有一种专门为并行而设计的语言,将是最好的解决方案。因为现有的语言大多的针对单核处理器的,现在的并发多数是由操作系统完成,用现行语言来编写并发程序的技术还显得不是很成熟。如果能有一种专门为并发而设计的语言,将会大幅度的提高程序的运行效率。当然这只是我的小想法而已。
3、内容一览:
1)程序和进程的概念区别
2)线程的概念
3)多线程的调度问题
4)线程安全问题(当多个线程同时访问一个变量时)
程序和进程
一般意义上的程序可以认为是在特定操作系统上的可执行文件。也就是源码经过编译、链接而形成的可执行文件,它依赖于执行的操作系统,因为正是操作系统提供了该程序运行的环境(运行库等)。它是一个静态的概念。
而进程,是一个动态的概念,它有自己的地址空间,能执行一些操作。程序的执行都会伴随着进程的生成,一个程序的执行会产生一个或多个进程。
所以可认为进程是程序的动态概念。
总结下程序和进程的区别
1)进程是动态的,而程序是静态的
2)进程有一个生命周期 ,而程序是指令集合,本身无“运动”含义。没有建立进程的程序不能作为一个单独的单位得到操作系认可
3)1个程序可产生多个进程,一个进程只对应一个程序
什么是线程
线程有时候又被称为轻量级进程,是程序执行的最小单元。和上文中一样的,一个进程可对应多个线程,而一个线程只属于一个进程。
进程的执行是以线程为单位进行得,比如说一个简单“hello world”程序只有一个线程,就是main()函数对应的线程。
线程的构成:
1)线程ID。用于标实线程
2)当前指令指针PC。标明下一指令执行点
3)寄存器集合和堆栈。该线程的可用空间
多线程
大多数软件应用中,线程的数量都不止一个。多线程可用并发的执行,并共享进程的全局便来那个和堆的数据。
多线程优势:
1)某个操作可能会陷入长时间的等待。采用多线程,当一个线程等待的时候,可执行其他线程,充分利用cpu。
2)某操作(如计算)可能会消耗大量时间,而导致和用户之间的交互中断。多线程可让一个线程负责计算,另一线程负责交互。
3)软件本身就要求并发操作,如多端下载软件
4)多核cpu,本身就具备同时执行多个线程的能力
线程访问权限
一般来说线程能访问进程内存中的所有数据,但实际应用中线程也有自己的空间
1)栈(可能被其他进程访问,但仍可认为是私有数据)
2)线程局部存储,一般只有很小容量
3)寄存器(包括PC寄存器)
线程调度
最好的情况是:当处理器数量大于要处理的线程数目的时候,所有线程都可以同时执行。实现真正意义上的并发。
而这种情况在现实中基本不可能。现实中的并发只是一种模拟出来的状态,特别是在单核处理对于多线程的时候。它通过让多个线程交替执行,每个线程执行很短时间,从表面上看,这些线程同时执行,实现并发。
每个线程都想被执行,但是每次执行的线程数量是有限的,所以就要有一种方法来从众多的线程中选出要执行的线程,现在讨论下单核的情况,多核的类似。
在操作系统中有专门的线程调度算法来实现,下面列几个简单的“调度算法”
1、“先进先出”策略。所有的线程组成一个队列,新生的线程加入到队列末尾,每次取队头执行。有一个缺陷就是,如果新生成的线程是紧急操作,需要操作系统尽快相应,这种调度方法就不能满足了。
2、按优先级调度。每个线程都有自己的优先级,并且是可以被操作系统修改的,调度时候每次选取优先级最高的执行。这种方法弥补了上一种方法的缺陷。对于需要及时相应的紧急事件,可以给他一个高优先级,这样就能在下次被调度。然而这种方法也有一个问题,也就是所谓的饥饿。如果某线程“看似”无关紧要,被给予一个低得优先级,以后每次产生的线程优先级都比他高,那么这个线程会一直得不到执行,成为饿死。一个解决的办法是随之事件的推移而提升线程的优先级。这样只要事件足够长,低优先级的线程也会获得高优先级而被执行。
从上面的分析来开,线程似乎有两个状态:执行和不执行(等待)。其实操作系统中的每个线程都对应三个状态。
线程的状态:
上面说了线程调度的问题,只有准备就绪的线程(这种线程称为就绪的线程),才能被调度。调度以后,线程就在处理器中被执行,这时线程的状态为运行时。如果该线程在等待某种事件的发生(如响应I/O),这种状态成为等待。
总结下线程的三种状态:
1)就绪:此时线程可以立刻运行i(如果该线程被调用的话)
2)运行:此时线程正在执行
3)等待:线程正在等待某件事的发生以便继续执行
在windows中,线程的状态有:
已初始化(Initialized):说明一个线程对象的内部状态已经初始化,这是线程创建过程中的一个内部状态,此时线程尚未加入到进程的线程链表中,也没有启动。
.
个线程来执行时,它只考虑处于就绪状态的线程。此时,线程已被加入到某个处理器的就绪线程链表中。
运行(Running):线程正在运行。该线程一直占有处理器,直至分到的时限结束,或者被一个更高优先级的线程抢占,或者线程终止,或者主动放弃处理器执行权,或者进入等待状态。
备用(Standby):处于备用状态的线程已经被选中作为某个处理器上下一个要运行的线程。对于系统中的每个处理器,只能有一个线程可以处于备用状态。然而,一个处于备用状态的线程在真正被执行以前,有可能被更高优先级的线程抢占。
已终止(Terminated):表示线程已经完成任务,正在进行资源回收。KeTerminateThread函数用于设置此状态。
等待(Waiting):表示一个线程正在等待某个条件,比如等待一个分发器对象变成有信号状态,也可以等待多个对象。当等待的条件满足时,线程或者立即开始运行,或者回到就绪状态。
转移(Transition):处于转移状态的线程已经准备好运行,但是它的内核栈不在内存中。一旦它的内核栈被换入内存,则该线程进入就绪状态。
延迟的就绪(DeferredReady):处于延迟的就绪状态的线程也已经准备好可以运行了,但是,与就绪状态不同的是,它尚未确定在哪个处理器上运行。当有机会被调度时,或者直接转入备用状态,或者转到就绪状态。因此,此状态是为了多处理器而引入的,对于单处理器系统没有意义。
门等待(GateWait):线程正在等待一个门对象。此状态与等待状态类似,只不过它是专门针对门对象而设计。
他们之间的转换图如下:
更多关于调度的知识可参见《现代操作系统》《windows内核分析》
可抢占线程和不可抢占线程
可抢占线程就是说,在该线程用完自己的时间片以后,操作系统会强制把该线程切出,以便执行其他线程。
而不可抢占线程则是线程不能强制切出,除非他自己放弃cpu的使用权而终止线程,而不是靠时间片的用尽而强制切出。不可抢占线程的线程切换时间是确定的,当该线程自愿切出时发生。
线程安全
多线程并发时,在访问数据方面会出现一些问题。特别是当多个线程访问同一个变量的时候。
下面将用一个例子来说明可能出现的问题:
线程A、线程B都对变量X进行操作,操作顺序如下:
1)线程A对X赋值
2)线程A对X自加
3)线程B使用X的值(比如说把它赋值给另一个变量)
代码经过编译之后,在处理器中执行代码时,通常一个很简单的运算(如自家运算)都会被分为多个步骤执行(指令流水)。
比如当线程A对X自加运算的时候,编译后的自加运算共分为三步,当没有执行完这三步的时候,可能线程A就会被切出(比如说有需要即时响应的操作发生)。也就是说线程A在对数据还没处理完全的时候被切出了,这样当线程B执行的时候,使用的X值将不是我们期望的值,显然,发生了错误。
解决策略:
上面问题的出现本质上是因为一个不应该被打断的操作被强行中断了,那么有一种解决的办法就是设置一种规定一些操作,在执行的时候不能被中断。这样就避免了操作还没完成就被换出的情况。把这些简单的操作称为原子的操作。windows中对于这种操作也有支持。
但是这种策略只适用于简单的情况。对于复杂的情况,我们用一种称为同步与锁的机制来实现。
同步与锁
简单的说,就是在一个线程对数据访问结束之前,其他线程不能对这个数据进行访问。这样的话,对数据的访问就原子化了。
这种机制的实现也很简单:每个线程对数据访问的时候都会尝试获取锁,当访问结束后释放。在获取锁的时候如果有线程在访问数据,就会获取失败,这时候线程会等待, 直到访问数据的那个线程释放锁。
二元信号量
是最简单的一种锁,它只有两个状态:占用与非占用。它用于只能被一个线程访问的资源。只有资源状态为非占用 的时候,才能被线程获取,获取之后修改资源状态为占用,访问结束后修改资源状态为非占用。
信号量
稍微复杂一些,他适用于可以被多个线程同时访问的资源。一个初始值为N=n的信号量能被n个线程同时访问。当要访问数据的时候,先查看N值,(N的值代表还有多少个线程能访问资源)如果N值大于0,该线程能访问资源,线程进入后把N值减一。当访问结束后N值+1.
如果信号量的值小于0,则进入等待状态。
互斥量
和二元信号相似,资源只能被一个线程访问,但是同一个信号量只能被获取该信号量的线程释放,也就是说对于二元信号量,同一个互斥量可以被别的(任意)线程释放。相对二元信号量来说更严格了。
临界区
是比互斥量更严格的同步手段。临界区和上面的区别在于,互斥量和信号量在任何进程都是可见的,也就是说,一个进程创建了互斥量和信号量,在其他进程都是可见的,而临界区的作用范围仅限于本进程。
读写锁
读写锁用于更加通用的场合。对于同一个数据,多个进程同时读是没问题的,但是如果有线程要对数据进行修改,就要使用同步手段来避免出错。对于同一个读写锁,有两种获取方式:
1、共享的,对数据只进行读操作,可以多个线程同时进行
2、独占的。会修改数据,在修改完成之前,不能有其他线程操作数据
当锁处于自由状态时,以任何一种方式获取锁都会成功,并将锁至于相应的状态。
如果锁处于共享状态,那么其他与以共享方式获取锁的线程都能成功,共同读数据。
对于独占式获取的,则要等到以共享方式获取的所有线程释放后(锁重新回到自由状态)才能获取。并且对于以独占方式获取的锁,其他任何对锁的请求都不会成功。
条件变量
条件变量类似于一个发令枪,可以有多个线程等待枪响,枪响的时候,这些等待枪响的线程会同时恢复执行。发令枪何时响也可由线程来决定。
也就是说,条件变量可以让多个线程等待某件事的发生,当时间发生时(条件变量被唤醒),所有的线程可以一起恢复执行。