第三章第三章
线程线程//进程安全进程安全
进程和线程是两个范围不同的概念。进程是程序在计算机
上的一次执行活动。运行一个程序,相当于启动了一个进程。
进程是操作系统进行资源分配的单位,通俗地讲,是一个正
在执行的程序。
线程是进程中的一个实体,是被系统独立调度和分派的基
本单位,它可与同属一个进程的其它线程共享进程所拥有的
全部资源。一个线程可以创建和撤消另一个线程,同一进程
中的多个线程之间可以并发执行。比如,一个在线播放软件,
在播放歌曲的同时还可以进行下载,就可认为这两件工作由
不同的线程完成。
线程和进程的开发和相关操作,在程序设计中具有重要地
位,线程和进程的安全和系统的安全息息相关。对于不够熟练
的程序员来说,很容易出现安全隐患,而这些安全问题又具
有不间断发生,难于调试等特点。
一般说来,线程的安全性主要来源于其运行的并发性和对
资源的共享性;进程的安全性主要在应用级别,在于其对系
统的威胁性,不过对于系统软件的开发者,进程安全的考虑
需要更加深入。
本章主要针对线程和进程开发过程中的安全问题进行讲述,
首先基于面向对象语言,讲解线程的的基本机制,然后讲解
线程操作过程中的几个重要的安全问题:线程同步安全、线
程协作安全、线程死锁、线程控制,最后讲解进程安全。
线程机制
为什么需要线程
由于Java在线程操作方面具有较好的面向对象特性,也具有一定的代
表性本章基于Java语言进行讲解。实际上,多线程最直观的说法是:让应
用程序看起来好像同时能做好几件事情。为了表达这个问题,我们用一个
案例来说明。比如,需要在控制台上每隔1秒钟打印一个欢迎信息。代码
如下所示:
public class P03_01
{
public static void main(String[] args)
{
while(true)
{
("Welcome");
try
{
(1000);
}catch(Exception ex){}
}
("其他工作"); //代码行1
}
}
该程序似乎没有什么问题,运行时,"Welcome"也能不断打印。但是,
我们发现,打印函数中的while循环是个死循环,也就是说,这个死循环不
运行完毕,程序将不能作其他事情。比如,程序中的代码行1永远也无法
运行。这就给程序的功能形成了巨大的阻碍。
在实际应用开发的过程中,经常会出现一个程序看起来同时作好几件
事情的情况,如က� 程序进行一个用时较长的计算,希望该计算进行的时候,
程序还可以做其他事情;
程序进行一个用时较长的计算,希望该计算进行的时候,程序还可
以做其他事情;
软件要能够接受多个客户的请求,而让客户感觉不出等待;
媒体播放器在播放歌曲的同时也能下载电影;
财务软件在后台进行财务汇总的同时还能接受终端的请求;等等。
在这些情况下,多线程就能够起到巨大的作用。
线程和进程的关系很紧密,进程和线程是两个不同
的概念,但是进程的范围大于线程。通俗地说,进程
就是一个程序,线程是这个程序能够同时做的各件事
情。比如,媒体播放机运行时就是一个进程,而媒体
播放机同时做的下载文件和播放歌曲,就是两个线程。
以上代码如果用线程来进行开发,在Java语言里
面,就可以用如的方式(其他语言类似)
。
运行,就会发现,此时“打印欢迎信息”和
“其他工作”就“同时”做了。
线程机制和生命周期
每个程序至少自动拥有一个线程,称为主线程。当程序加载到内存时,
启动主线程。从上节的程序可以看出,代码行:
实际上相当于实例化一个新的线程对象,并运行该线程中的run()函数。
该线程的运行并不影响主线程向下执行,这是为什么呢?
这是由于多线程的机制实际上相当于CPU交替分配给不同的代码段来
运行:也就是说,某一个时间片,某线程运行,下一个时间片,另一个线
程运行,各个线程都有抢占CPU的权利,至于决定哪个线程抢占,是操作
系统需要考虑的事情。由于时间片的轮转非常快,用户感觉不出各个线程
抢占CPU的过程,看起来好像计算机在“同时”做好几件事情。
WelcomeThread wt = new WelcomeThread();
();
一个线程有从创建、运行到消亡的过程,称为线程的生命周期。用线
程的状态(state)表明线程处在生命周期的哪个阶段。线程有创建、可运
行、运行中、阻塞、死亡五种状态。通过线程的控制与调度可使线程在这
几种状态间转化。这五种状态详细描述如下:
1:创建状态:使用new运算符创建一个线程后。该线程仅仅是一个空
对象,系统没有分配资源。
2:可运行状态:使用start()方法启动一个线程后,系统分配了资源,
使该线程处于可运行状态(Runnable)。
3:运行中状态:占有CPU,执行线程的run()方法。
4:阻塞状态:运行的线程因某种原因停止继续运行。
5:死亡状态:线程结束。
线程的安全隐患可能出现在各个状态。一般说来,
线程的安全性来源于两个方面:
1:多个线程之间可能会共享进程的内存资源。
2:CPU的某个时间片分配给哪个线程使用,默认
情况下无法由用户控制。
多线程的安全问题比较复杂,解决方法繁多,在这
里我们阐述几个比较典型的安全问题。
线程同步安全
线程同步
默认情况下,线程都是独立的,而且异步执行,线程中包含了运行时
所需要的数据或方法,而不需要外部的资源或方法,也不必关心其它线程
的状态或行为。但是在多个线程在运行时共享数据的情况下,就需考虑其
他线程的状态和行为,否则就不能保证程序的运行结果的正确性。在某些
项目中,经常会出现线程同步的问题,即:多个线程在访问同一资源时,
会出现安全问题。本节基于一个简单的案例,针对线程的同步问题进行阐
述。
所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调
用就不返回,同时其它线程也不能调用这个方法。通俗地讲,一个线程是
否能够抢占CPU,必须考虑另一个线程中的某种条件,而不能随便让操作
系统按照默认方式分配CPU,如果条件不具备,就应该等待另一个线程运
行,直到条件具备。
案例分析
给出一个案例:有若干张飞机票,2个线程去卖它们,要求
没有票时能够提示:没有票了。以最后剩下3张票为例。首先
用传统方法来编写这段代码。
代码如所示。
运行,控制台打印如下:
这段程序貌似没有问题。但是它是很不安全的,并且这种不安全性很
难发现,会给项目后期维护带来巨大的代价。
观察程序中的代码行1处的注释,当只剩下一张票时,线程1卖出了最
后一张票,接着要运行ticketNum--,但在ticketNum--还没来得及运行的时
候,线程2有可能抢占CPU,来判断当前有无票可卖,此时,由于线程1还
没有将ticketNum--,当然票数还是1,线程2判断还可以买票,这样,最后
一张票卖出了两次。当然,上面的程序中,没有给线程2以买票的机会,
实际上票都由线程1卖出,我们看不出其中的问题。为了让大家看清这个
问题,我们模拟线程1和线程2交替卖票的情况。
将的代码改为:
该代码中,增加了一行:程序休眠1000毫秒,让另一个线
程来抢占CPU。运行,控制台打印如下:
最后一张票被卖出两次,系统不可靠。
更为严重的是,该问题的出现很具有随机性。比如,有些
项目在实验室运行阶段没有问题,因为哪个线程抢占CPU,
是由操作系统决定的,用户并没有权利干涉,也无法预测,
所以,项目可能在商业运行阶段出现了问题,等到维护人员
去查问题的时候,由于问题出现的随机性,问题可能就不出
现了。这种工作往往给维护带来了巨大的代价。
以上案例是多个线程消费有限资源的情况,该情况下还有
很多其他案例,如:多个线程,向有限空间写数据时:
线程1写完数据,空间满了,但没来得及告诉系统;
此时另一个线程抢占CPU,也来写,不知道空间已满,
造成溢出。
解决方案
怎样解决这个问题?很简单,就是让一个线程卖票时其他线程不能抢
占CPU。根据定义,实际上相当于要实现线程的同步,通俗地讲,可以给
共享资源(在本例中为票)加一把锁,这把锁只有一把钥匙。哪个线程获
取了这把钥匙,才有权利访问该共享资源。
有一种比较直观的方法,可以在共享资源(如“票”)每一个对象内
部都增加一个新成员,标识“票”是否正在被卖中,其他线程访问时,必
须检查这个标识,如果这个标识确定票正在被卖中,线程不能抢占CPU。
这种设计理论上当然也是可行,但由于线程同步的情况并不是很普遍,仅
仅为了这种小概率事件,在所有对象内部都开辟另一个成员空间,带来极
大的空间浪费,增加了编程难度,所以,一般不采用这种方法。现代的编
程语言的设计思路都是把同步标识加在代码段上,确切的说,是把同步标
识放在“ 访问共享资源(如卖票)的代码段”上。
不同语言中,同步代码段的实现模型类似,只是表达方式有些不同。
这里以Java语言为例,在Java语言中,synchronized关键字可以解决这个
问题,整个语法形式表现为:
注意,synchronized后的“同步锁对象”,必须是可以被各个线程共享
的,如this、某个全局标量等。不能是一个局部变量。
其原理为:当某一线程运行同步代码段时,在“同步锁对象”上置一
标记,运行完这段代码,标记消除。其他线程要想抢占CPU运行这段代码,
必须在“同步锁对象”上先检查该标记,只有标记处于消除状态,才能抢
占CPU。在上面的例子中,this是一个“同步锁对象”。
synchronized(同步锁对象)
{ // 访问共享资源,需要同步的代码段
}
因此,在上面的案例中,可以将将卖票的代码用
synchronized代码块包围起来,“同步锁对象”取this。
如代码所示。
运行,可以得到如下效果。
这说明程序运行完全正常。
从以上代码可以看出,该方法的本质是将需要独占CPU的代码用
synchronized(this)包围起来。如前所述,一个线程进入这段代码之后,就
在this上加了一个标记,直到该线程将这段代码运行完毕,才释放这个标
记。如果其他线程想要抢占CPU,先要检查this上是否有这个标记。若有,
就必须等待。
但是可以看出,该代码实际上运行较慢,因为一个线程的运行,必须
等待另一个线程将同步代码段运行完毕。因此,从性能上讲,线程同步是
非常耗费资源的一种操作。我们要尽量控制线程同步的代码段范围,理论
上说,同步的代码段范围越小,段数越少越好,因此在某些情况下,推荐
将小的同步代码段合并为大的同步代码段。
实际上,在Java内,还可以直接把synchronized关键字直
接加在函数的定义上,这也是一种可以推荐的方法。
不过,值得一提的是,如果不能确定整个函数都需要同步,
那就要尽量避免直接把synchronized加在函数定义上的做法。
如前所述,要控制同步粒度,同步的代码段越小越好,
synchronized控制的范围越小越好,否则造成不必要的系统开
销。所以,在实际开发的过程中,要十分小心,因为过多的
线程等待可能造成系统性能的下降,甚至造成死锁。
线程协作安全
线程协作
有些情况下,多个线程合作完成一件事情的
几个步骤,此时线程之间实现了协作。如一个
工作需要若干个步骤,各个步骤都比较耗时,
不能因为它们的运行,影响程序的运行效果,
最好的方法就是将各步用线程实现。但是,由
于线程随时都有可能抢占CPU,可能在前面一
个步骤没有完成时,后面的步骤线程就已经运
行,该安全隐患造成系统得不到正确结果。
案例分析
给出一个案例:线程1负责完成一个复杂运算(比
较耗时),线程2负责得到结果,并将结果进行下一
步处理。如:某个科学计算系统中,线程1负责计算
1-1000各个数字的和(暂且认为它非常耗时),线程2
负责得到这个结果并且写入数据库。
读者首先想到的是将耗时的计算放入线程。这是正
确的想法。首先用传统线程方法来编写这段代码,代
码如所示。
该程序貌似没有问题,也能够打印正确结果,但是和上一
节的例子一样,它也是很不安全的,这种不安全性也很难发
现,也会给项目后期维护带来巨大的代价。该程序的安全隐
患在哪里呢?
观察cal()函数中的代码,当线程th1运行后,线程th2运行,
此时,线程th2随时可能抢占CPU,而不一定要等线程th1运行
完毕。当然,在上面的例子中,可能因为线程th1运行较快,
th2在它运行的过程中没有抢占CPU,“碰巧”得到了正确结
果,但是如果让线程th2抢占CPU,这样,系统可能得不到正
确结果。为了解释这个问题,将的代码改为
该代码中,增加了一行:程序休眠1毫秒,让另一个线程来
抢占CPU。运行,控制台打印如下:
很显然,这个结果不是我们所需要的。
那为什么sum得到的结果为1呢?很明显,线程th1的start
函数运行时,相当于让求和过程以多线程形式运行,在它“
休眠”之际,th2就抢占了CPU,在求和还没开始做或只完成
一部分时就打印sum,导致得到不正常结果。
解决方案
怎样解决?显而易见,方法是:在运行一个线程时,命令其他线程等
待该线程运行完毕,才能抢占CPU进行运行。对于该问题,不同语言解决
方法类似。以Java语言为例,在Java语言中,线程的join()方法可以解决
这个问题。
见代码
运行正常。实际上,该程序相当于摒弃了“线程就是为了程序看起来
同时做好几件事情”的思想,将并发程序又变成了顺序的,如果线程th1
没有运行完毕的话,程序会在()处堵塞。如果cal()函数耗时较长,程
序将一直等待。一般的方法是,可以将该工作放在另一个线程中,这样,
既不会堵塞主程序,又能够保证数据安全性。
见代码
线程死锁安全
线程死锁
死锁(DeadLock),是指两个或两个以上的线程在执行过程中,因争夺资源而造
成的一种互相等待的现象。此时称系统处于死锁状态,这些永远在互相等待的线
程称为死锁线程。
产生死锁的四个必要条件是:
互斥条件:资源每次只能被一个线程使用。如前面的“线程同步代码段”
,就是只能被一个线程使用的典型资源;
请求与保持条件:一个线程请求资源,但因为某种原因,该资源无法分配
给它,于是该线程阻塞,此时,它对已获得的资源保持不放;
不剥夺条件:进程已获得的资源,在未使用完之前,不管其是否阻塞,无
法强行剥夺;
循环等待条件:若干进程之间互相等待,形成一种头尾相接的循环等待资
源关系。
这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只
要上述条件之一不满足,就不会发生死锁。
案例分析
以Java语言为例,死锁一般来源于代码段的同步,当一段同步代码被
某线程运行时,其他线程可能进入堵塞状态(无法抢占CPU),而刚好在该
线程中,访问了某个对象,此时,除非同步锁定被解除,否则其他线程就
不能访问那个对象。这可以称为“线程正在等待一个对象资源”。如果出
现一种极端情况,一个线程等候另一个对象,而另一个对象又在等候下一
个对象,以此类推。这个“ 等候链”如果进入封闭状态,也就是说,最后
那个对象等候的是第一个对象,此时,所有线程都会陷入无休止的相互等
待状态,造成死锁。尽管这种情况并非经常出现,但一旦碰到,程序的调
试将变得异常艰难。
在这里给出一个死锁的案例,如代码
这段程序也貌似没有问题。但是和上一节的例子一样,它也是很不安
全的,这种不安全性也很难发现。
观察run()函数中的代码,当th1运行后,进入代码段1,锁定了S1,如
果此时th2运行,抢占CPU,进入代码段3,锁定S2,那么th1就无法运行
代码段2,但是又没有释放S1,此时,th2也就不能运行代码段4。造成互
相等待。为了模拟这个过程,我们在程序中增加让其休眠的代码,好让另
一个线程来抢占CPU。将的代码改为
该代码中,增加了一行:程序休眠1000毫秒,让另一个线程来抢占
CPU。运行,控制台打印如下:
两个线程陷入无休止的等待。其原因是,线程th1进入代码段1后,线
程2抢占CPU,锁定了S2,而线程th1对S1的锁定又没有解除,造成线程
th2无法运行下去,当然,由于线程th2锁定了S2,线程th1也无法运行下
去。
死锁是一个很重要的问题,它能导致整个应用程序慢慢终止,尤其是
当开发人员不熟悉如何分析死锁环境的时候,还很难被分离和修复。
解决方案
就语言本身来说,尚未直接提供防止死锁的帮助措施,需要我们通过谨慎的设
计来避免。一般情况下,我们主要是针对死锁产生的四个必要条件来进行破坏,
用以避免和预防死锁。在系统设计、线程开发等方面,注意如何不让这四个必要
条件成立,如何确定资源的合理分配算法,避免线程永久占据系统资源。
以Java为例,Java并不提供对死锁的检测机制。但可以通过java thread dump
来进行判断:一般情况下,当死锁发生时,Java虚拟机处于挂起状态,thread
dump可以给出静态稳定的信息,从操作系统上观察,虚拟机的CPU占用率为零,
这时可以收集thread dump,查找"waiting for monitor entry"的线程,如果大量
thread都在等待给同一个地址上锁,说明很可能死锁发生了。
解决死锁没有简单的方法,这是因为线程产生死锁都各有各的原因,而且往往
具有很高的负载。从技术上讲,可以用如下方法来进行死锁排除:
可以撤消陷于死锁的全部线程。
可以逐个撤消陷于死锁的进程,直到死锁不存在。
从陷于死锁的线程中逐个强迫放弃所占用的资源,直至死锁消失。
提示:
关于死锁的检测与解除,有很多重要算法,如资源分配算法、银行家
算法等。在操作系统的一些参考资料中应该可以进行足够了解。
很多软件产品内置了死锁解决策略,可做参考。如:
数据库死锁。一个连接占用了另一个连接所需的数据库锁,它可能
阻塞另一个连接。如果两个或两个以上的连接相互阻塞,产生死锁。
该情况下,一般会强制销毁一个连接(通常是使用最少的连接),
并回滚其事务。这将释放所有与已经结束的事务相关联的锁,至少
允许其他连接中有一个可以获取它们正在被阻塞的锁。
资源池耗尽死锁。资源池太小,而每个线程需要的资源超过了池中
的可用资源,产生死锁。此时可以增加连接池的大小或者重构代码,
以便单个线程不需要同时使用很多资源。
线程控制安全
安全隐患
线程控制主要是对线程生命周期的一些操作,如暂停、继续、消亡等。本节以
Java语言为例,讲解线程控制中的一些安全问题。Java中提供了对线程生命周期
进行控制的函数:
stop():停止线程;
suspend():暂停线程的运行;
resume():继续线程的运行:
destroy():让线程销毁;等等。
线程生命周期中的安全问题主要体现在:
线程暂停或者终止时,可能对某些资源的锁并没有释放,它所保持的任何资源都会
保持锁定状态;
线程暂停之后,我们无法预计它什么时候会继续(一般和用户操作有关),如果对某
个资源的锁长期被保持,其他线程在任何时候都无法再次访问该资源,极有可能造
成死锁。
针对这个问题,为减少出现死锁的可能,Java 中,将Thread的stop(),suspend
(),resume()以及destroy()方法定义为“已过时”方法,不再推荐使用。
案例分析
如前所述,线程的暂停和继续,早期采用suspend()和resume()方法,
但是容易发生死锁。以线程暂停为例,调用suspend()的时候,目标线程
会停下来,但却仍然持有在这之前获得的锁定。此时,其他任何线程都不
能访问锁定的资源,除非被“挂起”的线程恢复运行。如果它们想恢复目
标线程,同时又试图使用任何一个锁定的资源,就会造成死锁。
下面给出一个案例,来说明这个问题。屏幕上不断打印欢迎信息,点
击按钮,打印工作暂停;再点击,继续打印。
传统代码如
如果点击“暂停”按钮,则暂停打印;再点击,继续打印。
如上所述,该代码实际上在事件响应中用suspend()和resume()来控制
线程的暂停和继续,是不安全的。
解决方案
解决该问题,常见的方法有如下几种:
1:当需要暂停时,干脆让线程的run()方法结束运行以释放
资源(实际上就是让该线程永久结束);继续时,新开辟一个线
程继续工作。怎样让run()方法结束呢?一般可用一个标志告
诉线程什么时候通过退出自己的run()方法来中止自己的执行。
2:将线程暂停或继续,不使用suspend()和resume(),可
在Thread类中置入一个标志,指出线程应该活动还是挂起。
若标志指出线程应该挂起,便用wait()命其进入等待状态。若
标志指出线程应当恢复,则用一个notify()重新启动线程。
3:不推荐使用stop()来终止阻塞的线程,而应换用由
Thread提供的interrupt()方法,以便中止并退出堵塞的代码。
进程安全
进程概述
进程是一个执行中的程序,对每一个进程来说,都有自己
独立的一片内存空间和一组系统资源。进程由进程控制块、
程序段、数据段三部分组成。在进程概念中,每一个进程的
内部数据和状态都是完全独立的。
一个进程可以包含若干线程,线程可以帮助应用程序同时
做几件事(比如一个线程向磁盘写入文件,另一个则接收用户
的按键操作并及时做出反应,互相不干扰)。进程也有运行、
阻塞、就绪三种状态,并随一定条件而相互转化。
进程安全问题
由于进程的独立性,从应用的角度讲,进程安全比线程安
全更受重视,一般针对已有的进程进行安全方面的控制。如:
在系统安全中发现并清除病毒进程;
在网络应用中,优化守护进程或端口扫描进程;等等。
不过,从开发者(编程)的角度,进程的安全所需要考虑
的问题和线程类似,但由于线程能够共享进程的资源,所以
线程安全一般考虑的问题比进程安全多。不过,对于开发多
个进程能够运行的系统软件(如操作系统),进程的安全就
应该重点考虑了。一般情况下,此时考虑的问题和线程安全
类似,因为在这种软件中,各个进程在使用系统有限的资源,
和线程安全中考虑的问题类似,在此不再叙述。
小结
本章主要针对线程和进程开发过程中的安全
问题进行讲述。从开发者角度,首先讲解了线
程的的基本机制,然后讲解线程操作过程中几
个重要的安全问题:线程同步安全、线程协作
安全、线程死锁、线程控制安全等。最后讲解
了进程安全。
练习
1:将中的同步代码写在一个同步函数中。
2:线程1首先用Pen写字,然后用Pencil写字;线程2首先用Pencil写
字,然后用Pen写字;编写一个因为线程1等待Pencil,线程2等待Pen而
造成死锁的例子,并提出解决方法。 3:两个线程,向空间有限的数组中
写数据。写一段代码,具有数组溢出的安全隐患,并提出解决方案。
4:界面上有个小红球,要求能够慢慢掉下来然后弹起来;为了逼真,
当球在比较上方的时候,球比较大,球落下时,慢慢变小;在界面右下角
有一个“暂停”按钮,可以让动画暂停;动画暂停之后又可以点击按钮让
动画继续运行。
5:举例说明进程安全和线程安全问题所考虑的问题的不同之处?
6:Oracle数据库中,多个用户访问同一数据,可能会造成
死锁,请你设计一个案例,进行测试。并观察其解决方法。
7:很多语言中提供了定时器(Timer),让某个功能定时执
行,该功能也可以用线程实现,比较两种方法的优劣。
8:编写一个程序,每隔一秒,界面上方落下来一个字母。
字母落到界面底端,消失,这里怎样控制线程的开始和结束
?
9:飞机射击游戏中线程是怎样应用的?
10:将代码中同步段改为同步方法。