【转】Linux下的多线程编程背景知识

时间:2023-03-09 04:34:28
【转】Linux下的多线程编程背景知识

1. 进程和线程

线程(thread)技术早在60年代就被提出,但真正应用多线程到操作系统中去,是在80年代中期,solaris是这方面的佼佼者。传统的 Unix也支持线程的概念,但是在一个进程(process)中只允许有一个线程,这样多线程就意味着多进程。现在,多线程技术已经被许多操作系统所支 持,包括Windows/NT,当然,也包括Linux。

为什么有了进程的概念后,还要再引入线程呢?使用多线程到底有哪些好处?什么的系统应该选用多线程?我们首先必须回答这些问题。

使用多线程的理由之一是和进程相比,它是一种非常"节俭"的多任务操作方式。我们知道,在Linux系统下,启动一个新的进程必须分配给它独立的地址
空间,建立众多的数据表来维护它的代码段、堆栈段和数据段,这是一种"昂贵"的多任务工作方式。而运行于一个进程中的多个线程,它们彼此之间使用相同的地
址空间,共享大部分数据,启动一个线程所花费的空间远远小于启动一个进程所花费的空间,而且,线程间彼此切换所需的时间也远远小于进程间切换所需要的时
间。据统计,总的说来,一个进程的开销大约是一个线程开销的30倍左右,当然,在具体的系统上,这个数据可能会有较大的区别。

使用多线程的理由之二是线程间方便的通信机制。对不同进程来说,它们具有独立的数据空间,要进行数据的传递只能通过通信的方式进行,这种方式不仅费时,而且很不方便。线程则不然,由于同一进程下的线程之间共享数据空间,所以一个线程的数据可以直接为其它线程所用,这不仅快捷,而且方便。线程的执行可能是乱序的。多个线程可以“同时”运行,所以认为多个线程是“并发”的。多线程的目的是为了最大限度的利用CPU资源。当然,数据的共享也带来其他一些问题,有的变量不能同时被两个线程所修改,有的子程序中声明为static的数据更有可能给多线程程序带来灾难性的打击,这些正是编写多线程程序时最需要注意的地方。除了以上所说的优点外,不和进程比较,多线程程序作为一种多任务、并发的工作方式,当然有以下的优点:

  1. 提高应用程序响应。这对图形界面的程序尤其有意义,当一个操作耗时很长时,整个系统都会等待这个操作,此时程序不会响应键盘、鼠标、菜单的操作,而使用多线程技术,将耗时长的操作(time
    consuming)置于一个新的线程,可以避免这种尴尬的情况。
  2. 使多CPU系统更加有效。操作系统会保证当线程数不大于CPU数目时,不同的线程运行于不同的CPU上。
  3. 改善程序结构。一个既长又复杂的进程可以考虑分为多个线程,成为几个独立或半独立的运行部分,这样的程序会利于理解和修改。

多线程就象CPU在同时执行两条指令一样。事实上,只有多CPU系统可以同时执行两条指令,单CPU系统只是在不同的线程中进行切换;它切换得如此之快,以至于让人根本无法察觉,感觉这些线程看起来像是同时执行一样。

举个例子,你要写个函数,这个函数会下载一个内容全是名字的文件,然后将这个文件的内容排序,然后将排序好的内容存为另一个文件。如果这里有上百个这样的文 件,那么你可能会在一个循环中调用这个函数来处理每个文件:下载,排序,保存,下载,排序,保存,下载,排序,保存... 这三个步骤用到了你电脑上的不同资源:下载用到了网络,排序用到了CPU,保存文件用到了硬盘。同时,这三个操作都可能被延缓。例如,你下在文件的服务器可能很慢,或者你的带宽很小。这种情况先,使用多个线程,每个线程处理一个文件是一个比较明智的选择。这不仅能更好的利用你的带宽,而且当你的CPU工作的时候,网络也在工作。这将更有效的利用你的电脑。

但是,进程的调度由操作系统负责,线程的调度就需要我们自己来考虑。如何避免死锁,饥饿,活锁,资源枯竭等情况的发生,这会增加一定的复杂度。而且,由于线程之间共享内存,我们还需要考虑线程安全性的问题。

2. 多线程编程的难点
线程安全问题是多线程编程中的难点。如果你的代码所在的 进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的, 就是线程安全的。线程安全问题都是由全局变量及静态变量引起的。若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说, 这个全局变量是线程安全的;若有多个线程同时执行写操作,一般都需要考虑线程同步,否则的话就可能影响线程安全。
比 如一个ArrayList 类,在添加一个元素的时候,它可能会有两步来完成:1. 在 Items[Size] 的位置存放此元素;2. 增大 Size 的值。在单线程运行的情况下,如果 Size = 0,添加一个元素后,此元素在位置 0,而且 Size=1;而如果是在多线程情 况下,比如有两个线程,线程 A 先将元素存放在位置 0。但是此时 CPU 调度线程A暂停,线程 B 得到运行的机会。线程B也向此 ArrayList 添加元素,因为此时 Size 仍然等于 0 (注意哦,我们假设的是添加一个元素是要两个步骤哦,而线程A仅仅完成了步骤1),所以线程B也将元素存放在位置0。然后线程A和线程B都继续运行,都增 加 Size 的值。那好,我们来看看 ArrayList 的情况,元素实际上只有一个,存放在位置 0,而 Size 却等于 2。这就是“线程不安全”了。

类要成为线程安全的,首先必须在单线程 环境中有正确的行为。如果一个类实现正确(这是说它符合规格说明的另一种方式),那么没有一种对这 个类的对象的操作序 列(读或者写公共字段以及调用公共方法)可以让对象处于无效状态,观察到对象处于无效状态、或者违反类的任何不可变量、前置条件或者后置条件的情况。

此 外,一个类要成为线程安全的,在被多个线程访问时,不管运行时环境执行这些线程有什么样的时序安排或者交错,它必须仍然有如上所述的正确行为,并且在调用 的代码中没有任何额外的同步。其效果就是,在所有线程看来,对于线程安全对象的操作是以固定的、全局一致的顺序发生的。
正确性与线程安全性之间的关系非常类似于在描述 ACID(原子性、一致性、独立性和持久性)事务时使用的一致性与独立性之间的关系:从特定线程的角度看,由不同线程所执行的对象操作是先后(虽然顺序不定)而不是并行执行的。
引起线程安全问题的原因可分为以下几类:
1) 共享变量
大多数多线程程序共享访问相同的变量,但这就是棘手的东西。因为线程执行切换具有非确定性,线程执行的次序可能不同。数据共享可能会造成很严重的bug,但由于执行的非确定性,使得这种bug可能难以重现。通常可使用“锁”,也叫作互斥,来解决同时操作共享变量的问题。

在一个线程读取或者修改该某个共享变量前,它先试图获得一个锁。如果获得了这个锁,这个线程将继续对这个共享变量进行读写。反之,它将一直等待直到这个锁再次可用。一旦完成对共享变量的操作,线程就会“释放”这个锁。这样其他等待这个锁的线程就能获取它了。

例如,一个机器人把座位列表拿起来 (这个列表就是锁),检查后发现客户要求的座位还在,于是把相应的票取出来,把这个座位从列表中删去。最后机器人把列表放回去的动作,就相当于“释放了这 个锁“。如果另一个机器人需要查看座位列表但列表不在,它会一直等待直到座位列表再次可用。

写代码时,如果忘记对锁进行释放,就可能引入bug。这将导致死锁情况的发生,因为另外一个等待该锁释放的线程会一直挂在那里无事可做。

2)死锁
多线程编程还会遇到“死锁”的问题。通常用哲学家就餐问题的比喻来解释。五个哲学家围坐一个桌子吃意大利面条,但需要两个叉子。在每个哲学家之间有一个叉子(总共有5个)。哲学家用这个方法来吃面条:
  1. 理性地思考一会儿。
  2. 拿起你左边的叉子。
  3. 等待你右边的叉子能用。
  4. 拿起右边的叉子。
  5. 吃面条。
  6. 放下叉子。
  7. 跳到步骤1.

实际上他们会和旁边的人共享叉子,这方法看起来似乎能有效。但马上或者稍后桌子上每个人最后都会拿着左边的叉子在手挡同时等待右边的叉子。但因为每个人都拿着他们旁边的人等待的叉子同时也不会在他们吃之前放下他们,这些哲学家就在一个死锁状态。他们会拿着左边的叉子在手上又永远不会拿到右边的 叉子,所以他们永远不会吃到面条也用户不会放下他们左手上的叉子。哲学家都要饿死了(除了伏尔泰,它实际上是个机器人。没有意大利面条,他的电子大脑会爆 炸)

还存在一种被称为活锁的情况。当这种情况发生时,所有的线程都让出资源,导致任务不能继续进行下去。就像在大厅里迎面走近的两个人,他们都站到一边,等待对方先过去,结果两个人都卡住了。然后他们又同时试图走到对面,又互相阻碍了对方。他们持续地这样让开-走近,直到他们都筋疲力尽。

3)饥饿

由于别的并发的激活的过程持久占有所需资源,是莫个异步过程载客预测的时间内不能被激活。当一个进程永久性地占有资源,使得其他进程得不到该资源,就发生了饥饿。

在饥饿的情形下,系统不处于死锁状态中,因为有一个进程仍在处理之中,只是其他进程永远得不到执行的机会。而一旦发生下面四种情况之一,就会导致死锁的发生。

  • 相互排斥: 一个线程或者进程永远占有一共享资源,例如,独占该资源。
  • 循环等待: 进程A等待进程B,而后者又在等待进程C,而进程C又在等待进程A。
  • 部分分配: 资源被部分分配。例如,进程A和B都需要用访问一个文件,并且都要用到打印机,进程A获得了文件资源,进程B获得了打印机资源,但是两个进程不能获得全部的资源。
  • 缺少优先权: 一个进程访问了某个资源,但是一直不释放该资源,即使该进程处于阻塞状态。

  如果上面四种情形都不出现,系统就不会发生死锁。

3. 线程的生命周期

所谓的xx生命周期,其实就是某对象的包含产生和销毁的一张状态图。线程的生命周期各状态的说明如下:

  • New新建。新创建的线程经过初始化后,进入Runnable状态。
  • Runnable就绪。等待线程调度。调度后进入运行状态。
  • Running运行。
  • Blocked阻塞。暂停运行,解除阻塞后进入Runnable状态重新等待调度。
  • Dead消亡。线程方法执行完毕返回或者异常终止。

可能有3种情况从Running进入Blocked:

  • 同步:线程中获取互斥锁,但是资源已经被其他线程锁定时,进入Locked状态,直到该资源可获取(获取的顺序由Lock队列控制)
  • 睡眠:线程运行sleep()或join()方法后,线程进入Sleeping状态。区别在于sleep等待固定的时间,而join是等待子线程执行完。当然join也可以指定一个“超时时间”。从语义上来说,如果两个线程a,b, 在a中调用b.join(),相当于合并(join)成一个线程。最常见的情况是在主线程中join所有的子线程。
  • 等待:线程中执行wait()方法后,线程进入Waiting状态,等待其他线程的通知(notify)。

4. 线程的类型

  • 主线程:当一个程序启动时,就有一个进程被操作系统(OS)创建,与此同时一个线程也立刻运行,该线程通常叫做程序的主线程(Main Thread)。每个进程至少都有一个主线程,主线程通常最后关闭。
  • 子线程:在程序中创建的其他线程,相对于主线程来说就是这个主线程的子线程。
  • 守护线程:daemon thread,对线程的一种标识。守护线程为其他线程提供服务,如JVM的垃圾回收线程。当剩下的全是守护线程时,进程退出。
  • 前台线程:相对于守护线程的其他线程称为前台线程。