操作系统概念拾遗(四)
不为考试,重读操作系统——Operating System Concepts
多线程服务器也有一些潜在问题,第一个是关于在处理请求之前用以创建线程的时间,以及线程在完成工作之后就要被丢弃这一事实;第二个问题更为麻烦,如果允许所有并发请求都通过新线程来处理,那么没有限制在系统中并发执行的线程的数量,无限制的线程会耗尽系统资源,如CPU时间和内存。解决这个问题的一种方法是使用线程池(thread pool)。
线程池的主要思想是在进程开始时创建一定数量的线程,并放入到池中等待工作。服务器收到请求时,它会唤醒池中的一个线程(如果有可以用的线程),并将要处理的请求传递给它。一旦线程完成了服务,它会返回到池中在等待工作。如果池中没有可用的线程,那么服务器会一直等待直到有空线程为止。线程具有如下主要优点:通常用现有线程处理请求要比等待创建新的线程要快;线程池限制了在任何时候可用线程的数量。
更为高级的线程池的结构能动态调整池中线程的数量,以适应使用情况,这类结构提供了较小池的又一个好处,即在系统负荷低时减低内存消耗。java.util.concurrent包包含了线程池的API,当然还有其他的并发编程工具。Java API提供了几个线程池结构的变种。值得注意的有下面三个模型(在java.util.concurrent.Executors类的静态方法中):单线程执行器(newSingleThreadExecutor()),创建大小为1的线程池;固定线程执行器(newFixedThreadPool(int size)),创建大小固定的线程池;缓冲线程执行器(newCachedThreadPool()),创建无限制的线程池
线程特定数据,同属于一个进程的线程共享进程数据,事实上,这种数据共享提供了多线程编程的一种好处。不过在有些情况下每个线程可能需要一定数据的自我副本,这种数据称为线程特定数据(thread-specific data)。绝大多数线程库,包括Win32和Pthread,都提供了对线程特定数据的一定支持。Java也提供这种支持。乍一看Java好像不需要线程特定数据,因为如果要给每个线程独有数据,只需要继承Thread类并在继承类中成员变量。确实是,只要按这种方式构造类即可,然而,如果开发者不能控制线程创建的过程(例如,当使用线程池的时候),就需要另外的方法。Java API提供了ThreadLocal类来声明线程特定数据。ThreadLocal数据可以通过initialValue()或set()来初始化,线程可以使用get()查询ThreadLocal的数据,通常ThreadLocal数据被声明为statiic。
多线程编程的最后一个问题是内核与线程库之间的通信问题,这种协调允许动态调整内核线程的数量已保证其最好的性能。许多实现多对多或二级模型的系统在用户和内核线程之间设置一种中间数据结构。这种数据结构——通常是轻量级进程(LWP)。对于用户线程库,LWP表现为一种应用程序可以调度用户线程来运行的虚拟处理器。每个LWP与内核线程相连,该内核线程被操作系统调度到物理处理器上运行。如果内核线程阻塞(如在等待一个IO操作结束),LWP也阻塞,与LWP相连的用户级线程也阻塞。为了高效地运行,应用程序可能需要一定数量的LWP。考虑一个CPU约束的运行在单处理器上的应用程序。此时,一次只能运行一个线程,所以只要一个LWP就够了,但一个IO请求密集的应用程序可能需要多个LWP来执行。通常,每个并发阻塞系统调用需要一个LWP。例如,设想一下有五个不同文件读请求可能同时发生的情况,此时就需要五个LWP,因为一个都需要等待内核IO完成。如果进程只有四个LWP,那么第五个请求必须等待其中一个LWP从内核返回。
一种解决用户线程库与内核间通信的方法被称为调度程序激活(scheduler activation)。它按如下方式工作:内核提供一组虚拟处理器(LWP)给应用程序,应用程序可调度用户线程到一个可用的虚拟处理器上。
Windows XP线程,一个应用程序以独立进程方式运行,每个进程可包括一个或多个线程。通过使用线程库,同属一个进程的每个线程都能访问进程的地址空间。一个线程通常包括如下部分:一个线程ID,以唯一标示线程;一组寄存器集合,以表示处理器状态;一个用户栈,供线程在用户模式下运行,一个内核栈,供线程在内核模式下运行;一个私有存储区域,供各种运行库和动态链接库(DLL)使用。寄存器集合、栈和私有存储区域通常称为线程的上下文(context)。线程的主要数据结构包括:ETHREAD——执行线程库;KTHREAD——内核线程库;TEB——线程执行环境块。ETHREAD主要包括线程所属进程的指针和线程开始控制的子程序的地址。ETHREAD也包括相应的KTHREAD的指针;KTHREAD包括线程的调度和同步信息,另外,KTHREAD也包括内核栈(当线程在内核模式下运行时使用)和TEB的指针;ETHREAD和KTHREAD完全处于内核空间,这意味着只有内核可以访问它们,TEB是用户空间的数据结构,可供线程在用户模式下运行时访问。TEB除了包括许多其他域外,还包括用户模式栈和用于线程特定数据的数组(Windows称之为线程本地存储(thread-local storage))。
Linux提供了具有传统进程复制功能的系统调用fork(),还提供使用系统调用clone()创建线程的功能,Linux并不区分进程和线程。事实上,Linux在讨论程序控制流时,通常称之为任务而不是进程或线程。clone()被调用时,它传递一组标志,以决定父任务与子任务之间发生多少共享。例如,如果将CLONE_FS、CLONE_VM、CLONE——SIGHAND和CLONE_FILES标识传递给clone(),父任务和子任务将共享相同的文件系统信息(如当前工作目录)、相同的内存空间、相同的信号处理程序和相同的打开文件集。调用clone相当于之前介绍的创建线程,因为父任务和其子任务共享大多数资源,不过,如果当调用clone()时没有设置任何标志,则不会发生共享,导致类似于系统调用fork()提供的功能。
共享级别的变化是可能的,这源于Linux内核中任务表达的方式。系统中每个任务都有一个唯一的内核数据结构(struct task_struct),这个数据结构并不保存任务本身的数据,而是指向其他存储这些数据的数据结构的指针——如表示打开文件列表、信号处理信息和虚拟内存等的数据结构。当调用fork()创建新的任务时,它具有父进程的所有数据的副本。当调用系统调用clone()时,也创建了新的任务。不过,并非复制所有的数据结构,根据传递给clone()的标志集,新的任务指向父任务的数据结构。
浙公网安备 33010602011771号