线程
概念
线程
线程是CPU使用的基本单元,它由线程ID,程序计数器,寄存器集合和栈组成。它与属于同一个进程的其他线程共享代码段、数据段和其他操作系统资源,如打开文件和信号。
为什么要使用多线程
- 一个应用程序通常是作为一个具有多个控制线程的独立进程实现的
- 一个忙碌的web服务器可能有多个客户并发访问它
一种解决方法是让服务器作为单个进程运行以接受请求。当服务器收到请求时,它会创建另一个进程以处理请求。
进程创建很耗时间和资源。如果新进程与现有进程执行同样的任务,那么为什么需要这些开销呢?如果一个具有多个线程的进程能达到同样目的,那么将更为有效。
优点
- 响应度高:如果对一个交互程序采用多线程,即使其部分阻塞或执行较冗长的操作,那么该程序仍能继续执行,从而增加了对用户的响应程度。
- 资源共享:线程默认共享他们所属进程的内存和资源。代码和数据共享的优点是它能允许一个应用程序在同一地址空间有多个不同的活动线程。
- 经济:进程创建所需要的资源和内存的分配比较昂贵
- 多处理器结构的利用:多线程的优点之一是能够充分使用多处理器体系结构,以便每个进程能并行运行在不同的处理器上。
多线程模型
有两种不同方法来提供线程支持:用户层的用户线程或内核层的内核线程。用户线程收内核支持,而无需内核管理;而内核线程由操作系统直接支持和管理。
多对一模型
多对一模型将许多用户级线程映射到一个内核线程。
- 线程管理是由线程库在用户空间进行的,因而效率比较高
- 但是如果一个线程执行了阻塞系统调用,那么整个进程会阻塞。
- 由于任一时刻只能有一个线程能访问内核,多个线程不能并行运行在多处理器上。
一对一模型
一对一模型将每个用户线程映射到一个内核线程。
- 该模型在一个线程执行阻塞系统调用时,能允许另一个线程继续执行,所以它提供了比多对一模型更好的并发性能
- 它也允许多个线程能并行地运行在多处理器系统上
- 由于创建内核线程的开销会影响应用程序的性能,所以这种模型的绝大多数实现限制了系统所支持的线程数量。
多对多模型
多对多模型多路复用了许多用户线程到同样数量或更小数量的内核线程上。
- 开发人员可以创建任意多的用户线程,并且相应内核线程能在多处理器系统上并发执行
- 当一个线程执行阻塞系统调用时,内核能调度另一个线程来执行。
线程库
线程库为程序员提供创建和管理线程的API。
主要有两种方法来实现线程库:
- 第一种方法是在用户空间内提供一个没有内核支持的库。此库的所有代码和数据结构都存在于用户空间中。调用库中的一个函数只是导致了用户空间中的一个本地函数调用,而不是系统调用
- 第二种方法是执行一个由操作系统直接支持的内核级的库。此时,库的代码和数据结构存在于内核空间中。调用库中的一个API函数通常会导致对内核的系统调用
目前使用的三种主要的线程库是 POSIX Pthread,Win32,Java。
Java线程
线程是Java程序中程序执行的基本模型。
在Java程序中有两种创建线程的方法。
- 一种方法是创建一个新的类,它从Thread类派生,并重载它的run()方法。
- 另外一种更常使用的方法是定义一个实现Runnable接口的类。Runnable接口定义如下:
public interface Runnable
{
public abstract void run();
}
当一个类实现了Runnable时,它必须定义run()方法。而实现run()方法的代码被作为一个独立的线程执行。
创建Thread对象并不会创建一个新的线程,实际上是用start()方法来创建新线程。为新的对象调用start()方法需要做两件事:
- 在JVM中分配内存并初始化新的线程
- 调用run()方法,使线程适合在JVM中运行(注意从不直接调用run()方法,而是调用start()方法,然后它再调用run()方法)。
在Win32和Pthread中线程间共享数据很方便,因为共享数据被简单声明为全局数据。作为一个纯面向对象语言,Java没有这样的全局数据的概念。在Java程序中如果两个或更多的线程需要共享数据,通过向响应的线程传递对共享对象的引用来实现。
回忆一下,Pthread和Win32库中的父线程,它们在继续之前,使用pthread_join或WaitForSingleObject()(分别地)等待累加和线程结束。Java中的join()方法提供了类似的功能。
Java实际上识别两种不同类型的线程:后台线程和非后台线程。区别这两种线程的一个简单规则是:当所有的非后台线程退出后,JVM会被关闭。否则,两者是一样的。
后台线程是通过调用Thread类的setDeamon()方法并向该方法传递值true来创建的。
Java线程状态
Java线程可以有四个状态:
- 新建(new):当用new命令新建一个线程对象的时候,线程处于这个状态。
- 可运行(runnable):调用start()方法会为在JVM中的新线程分配内存,并将调用线程对象的run()方法。当线程的run()方法被调用的时候,线程状态从新建转移到可运行。可运行的线程可以被JVM选中运行。
- 阻塞(blocked):如果线程执行了一个阻塞语句(如执行I/O),或者调用了特定的Java Thread类的方法(如sleep()),线程变为阻塞状态。
- 死亡(dead):当线程的run()方法结束的时候,线程死亡。
多线程问题
系统调用fork()和exec()
在多线程程序中,系统调用fork()和exec()的语义有所改变。
如果程序中的一个线程调用fork(),那么新进程会复制所有线程,还是新进程只有一个线程?有的UNIX系统有两种形式的fork(),一种复制所有线程,另一种只复制调用了系统调用fork()的线程。
系统调用exec()的工作方式与进程所述的方式通常相同。也就是说,如果一个线程调用了系统调用exec(),那么exec()参数所指定的程序会替换整个进程,包括所有线程。
fork()的两种形式的使用与应用程序有关。如果调用fork()之后立即调用exec(),那么没有必要复制所有线程,因为exec()参数所指定的程序会替换整个进程。在这种情况下,只复制调用线程比较恰当。不过,如果在fork()之后独立进程并不调用exec(),那么独立进程就应复制所有线程。
取消
线程取消是在线程完成之前来终止线程的任务。
要取消的线程通常称为目标线程,目标线程的取消可在如下两种情况下发生:
- 异步取消:一个线程立终止目标线程
- 延迟取消:目标线程不断地检查它是否应终止。这允许目标线程有机会按照有序方式来终止自己。
信号处理
信号在UNIX中用来通知进程某个特定时间已经发生了。
根据需要通知信号的来源和事件的理由,信号可以同步或异步接收。
- 同步信号的例子包括非法访问内存或被0所除。在这种情况下,如果运行程序执行这些动作,那么久产生信号。同步信号发送到执行操作而产生信号的同一进程。
- 当一个信号由运行进程以外的时间产生,那么进程就异步接收这一信号。这种信号的例子包括使用特殊键(例如Ctrl+C键)或定时器到期。通常,异步信号被发送到另一个进程。
每个信号可能由两种可能的处理程序的一种来处理:
- 默认信号处理程序
- 用户定义的信号处理程序
每个信号都有一个默认的信号处理程序。这种默认动作可以用用户定义的信号处理程序来改写。
单线程程序的信号处理比较直接,信号总是直接发送给进程。不过,对于多线程程序,发送信号就比较复杂,因为进程可能有多个线程。信号会发送到哪里呢?通常有如下选择:
- 发送信号到信号所应用的线程
- 发送信号到进程内的每个线程
- 发送信号到进程内的某些固定线程
- 规定一个特定线程以接受进程的所有信号。
大多数多线程版UNIX允许线程描述它会接收什么信号和拒绝什么信号。
虽然Windows并不明确提供对信号的支持,但是他们能通过异步过程调用(APC)来模拟。APC工具允许用户线程指定一个函数,以便在用户线程收到特定时间通知时能被调用。顾名思义,APC与UNIX的异步信号相当。
线程池
线程池的主要思想是在进程开始时创建一定数量的线程,并放入池中等待工作。当服务器收到请求时,它会唤醒池中的一个线程(如果有可用的线程),并将要处理的请求传递给它。一旦线程完成了服务,它会返回池中再等待工作。如果池中没有可用线程,那么服务器会一直等待直到有空线程为止。
线程池具有如下主要优点:
- 通常用现有线程处理请求要比等待创建新的线程要快。
- 线程池限制了在任何时候可用线程的数量。这对那些不能支持大量并发线程的系统非常重要。
池中线程的数量可用通过一些因素如系统的CPU数量,物理内存的大小和并发客户请求的期望值来启发设置。更为高级的线程池的结构能动态调整池中线程的数量,以适应使用情况。
线程特定数据
同属一个进程的线程共享进程数据。事实上,这种数据共享提供了多线程变成的一种好处。
不过,在有些情况下每个线程可能需要一定数据的自我副本,这种数据称为线程特定数据。
调度程序激活
一种解决用户线程库与内核间通信的方法被称为调度程序激活。它按如下方式工作:内核提供一组虚拟处理器给应用程序。应用程序可调度用户线程到一个可用的虚拟处理器上。