在传统操作系统中,每个进程有一个地址空间和一个控制线程,这是进程的定义,经常存在在一个地址空间中准并行运行多个控制线程的情形,这些线程像分离的进程。
1 线程的使用
需要多线程的理由:
- 在许多应用中同时发生多种活动。其中某些活动随着时间的推移会被阻塞,通过将这些应用程序分解成可以准并行运行的多个顺序线程,程序设计模型会变得更简单
- 线程比进程更轻量级,所以它们比进程更容易(更快)创建,也更容易撤销
- 使用线程可以加强程序的性能
- 在多CPU系统中,多线程是有益的
线程与进程的不同:
- 同一个进程的线程之间共享该进程的公共内存。所以它们可以访问同一个文件
- 不同进程之间共享整个内存空间,但是每个进程只能访问特定的地址空间
构建服务器的三种方法
模型 | 特性 |
---|---|
多线程 | 并行性,阻塞系统调用 |
单线程进程 | 无并行性,阻塞系统调用 |
有限状态机 | 并行性,非阻塞系统调用,中断 |
2 线程模型
进程模型基于两种独立的概念:资源分组处理与执行。将这两种概念分离开会更有益,这就引入了线程这一概念。
理解进程
- 用某种方法将相关的资源集中在一起。进程有存放程序正文和数据以及其他资源的地址空间。
- 进程拥有一个执行的线程,可以简写为线程。
进程将资源集中在一起,而线程则是在CPU上被调度执行的实体
线程可以访问进程地址空间中的每一个内存地址,所以一个线程可以读写甚至清除另一个线程的堆栈。线程之间是没有保护的,原因是:1)不可能,2)没有必要。
第一列表项是进程的属性,而不是线程的属性。第一列给出了在一个进程中所有线程共享的内容,第二列给出了每个线程自己的内容。线程概念试图实现的是,共享一组资源的多个现成的执行能力,以便这些线程可以为完成某一任务而共同工作。
线程的状态:
- 运行(拥有CPU并且是活跃的)
- 阻塞(正在等待某个释放它的时间)
- 就绪(可被调度运行)
- 终止(该线程已运行完毕)
每个线程都有自己的一帧堆栈,供各个被调用但是还没有从中返回的过程使用。在该帧中存放了相应过程的局部变量以及过程调用完成之后使用的返回地址。通常每个线程会调用不同的过程,从而有不同的执行历史。这就是为什么每个线程需要有自己堆栈的原因。
线程在程序中带来的复杂性:
- 如果父进程拥有多个线程,那么它的子进程也拥有这些线程吗?
- 如果不是则该子进程可能会工作不正常,有些线程在子进程中是必要的
- 如果是,如果父进程在read调用阻塞了,是两个线程(子进程和父进程) 一起阻塞吗?如果read成功了,两个线程都得到该输入的副本吗?还是仅有父进程获得该副本?
- 跟线程共享数据结构的事实有关
- 如果一个线程关闭了某个文件,而其他线程在该文件上进行都操作会怎么办?
- 假设一个线程注意到几乎没有内存了,并开始分配更多的内存。工作到一半的时候,发生线程切换,新线程也注意到几乎没有内存了,并且开始分配内存,于是内存就可能被分配两次
3 POSIX线程(Portable Operating System Interface of UNIX)
为实现可移植的线程程序,IEEE在IEEE标准103.1c中定义了线程的标准。它定义的线程包叫做Pthread。大部分UNIX系统都支持该标准。所有Pthread线程都有某些特性,每一个都含有一个标记符,一组寄存器(包括程序计数器)和一组储存在结构中的属性。包括堆栈大小,调度参数以及使用线程需要的其他项目。
线程调用 | 描述 |
---|---|
Pthread_create | 创建一个线程 |
Pthread_exit | 结束调用的线程 |
Pthread_join | 等待一个特定的线程推出 |
Pthread_yield | 释放CPU来运行另一个线程 |
Pthread_attr_init | 创建并初始化一个线程的属性结构 |
Pthread_attr_destroy | 删除一个线程的属性结构 |
下面是一个使用线程的程序例子:
#include<pthread.h>
#include<stdio.h>
#include<stdlib.h>
#define NUMBER_OF_THREADS 10
void *printf_hello_world(void *tid)
{
printf("Hellon World.Greeting from thread %d.\n",tid);
pthread_exit(NULL);
}
int main()
{
pthread_t threads[NUMBER_OF_THREADS];
int status,i;
for(i = 0;i < NUMBER_OF_THREADS;i++)
{
printf("Main here.Creating thread %d\n",i);
status = pthread_create(&threads[i],NULL,printf_hello_world,(void*)i);
if(status != 0)
{
printf("Oops.pthread_create return error code %d.\n",status);
exit(-1);
}
}
exit(NULL);
}
有两种主要的方式可以实现线程包:
- 在用户空间中
- 在内核中
4 在用户空间实现线程
第一种方法是把整个线程包放在用户空间中,内核对线程包一无所知。从内核角度考虑,就是按正常的方式管理,即单线程进程。
在用户空间实现线程优点
- 可以在不支持线程的操作系统上实现,可以用函数库实现线程(最主要的优点)
- 进行类似的线程切换至少比陷入内核要快一个数量级,或者更多(极大的有点)
- 它允许每个进程有自己定制的调度算法
- 用户级线程还具有较好的扩展性
在用户空间实现线程的缺点
- 如何实现阻塞系统调用。假设没有任何击键之前,一个线程在读取键盘,让该线程实际进行系统调用是不可接受的,这会停止所有线程。
- 页面故障问题
- 如果一个线程开始运行,那么该进程中的其它线程就不能运行,除非第一个线程自动放弃CPU。在一个单独的进程内部,没有时钟中断,所以不能用轮转调度(轮流)的方式调度进程。除非某个线程能够按自己的意志进入运行时系统,否则调度程序就没有任何机会。
- 程序员通常在经常发生线程阻塞的应用中才希望使用多个线程(最大负面争议)
在用户空间管理线程时,每个进程需要有其专用的线程表,用来跟踪该进程中的线程。该线程表由运行时系统管理,当一个线程转换到就绪状态或阻塞状态时,在该进程表中存放重新启动该线程所需的信息,与内核在进程表中存放进程的信息一样。
5 在内核中实现线程
在内核中实现线程时,不需要运行时系统。每个进程也没有线程表。相反,在内核中有用来记录系统中所有线程的线程表。当某个线程希望创建或撤销一个已有线程时,他进行一个系统调用,这个系统调用通过对线程表的更新完成线程创建和更新工作。
在内核中实现线程的优点
- 所有能够阻塞线程的调用都以系统调用的形式实现,这与运行时系统相比,代价很可观。
当一个线程阻塞时,内核可以选择同一进程的其它线程,也可以选择另一个进程的线程。而运行时系统始终运行自己进程中的线程,直到内核剥夺它的CPU(没有可运行的线程存在)为止 - 由于在内核中创建或撤销线程的代价比较大,某些系统采用“环保”的处理方式,回收其线程。在莫个线程被撤销时,就把它标记为不可运行,但是其内核数据不受影响。然后,在必须创建一个新线程时,就重新启动某个旧线程,节省开销。
- 如果某个进程中的线程引起了页面故障,内核可以很方便地检查该进程是否有其他可运行的线程,如果有,在等待所需页面的时候可以选择另一个可运行的线程执行
在内核中实现线程的缺点:
- 当一个多线程进程创建一个子进程的时候,新进程是拥有跟远进程一样数量的线程,还是只有一个线程?
这取决于进程计划下一步做什么,如果他要调用exec来启动一个新的程序,或者一个线程是正确的选择,但是如果它继续执行,则应该复制全部线程 - 信号。信号是发给进程的,而不是发给线程。当一个信号到达时,应该由哪个线程处理它?当一个信号到达的时候,可以把他交给需要它的线程,但是当两个或者更多的线程注册了同一个信号,就会出现问题了。
6 混合实现
可以使用内核级线程,然后将用户级线程与某些或者全部内核线程多路复用起来。在这个模型中,每个内核级线程有一个可以轮流使用的用户级线程集合。
7 调度程序激活机制
调度程序激活工作的目标是模拟内核线程的功能,但是为线程包提供通常在用户空间才能实现的更好的性能和更大的灵活性。如果用户线程从事某种系统调用是安全的,那就不应该进行专门的非阻塞调用或者进行提前检查。
该机制工作的基本思路是:当内核了解到一个线程被阻塞之后(例如,由于执行了一个阻塞系统调用或者产生了一个页面故障),内核通知该进程的运行时系统,并且在堆栈中以参数形式传递有问题的线程编号和所发生事件的一个描述。这个机制叫做上行调用。
一旦如此激活,运行时系统就重新调度其线程,这个过程通常是这样的:把当前线程标记为阻塞并从就绪表中取出另一个线程,设置其寄存器,然后再启动之。稍后,当内核知道原来的线程又可运行时,内核又一次上行调用运行时系统,通知它这一事件。此时运行时系统按照自己的判断,或者立即重启被阻塞的线程,或者把它放入就绪表中稍后运行。
在某个用户级线程运行时发生一个硬件中断,被中断的CPU切换进核心态。如果被中断的进程对引起该中断的事件不感兴趣没那么在中断处理程序结束之后,就把中断的线程会发到中断之前的状态。不过,如果该进程对终端感兴趣,那么被中断的线程就不再启动, 代之为挂起被中断的线程。而运行时系统则启动对应的虚拟CPU,此时被中断线程的状态保存在堆栈中。
8 弹出式线程
当一个消息的到达导致系统创建一个处理该消息的线程,这种线程称为弹出式线程。由于这类线程相当新,没有历史–没有必须的寄存器,堆栈诸如此类的内容,每个线程从全新开始,每一个线程彼此之间都完全一样。这样,就有可能创建这一类线程。对该新线程指定所要处理的信息。使用弹出式线程的结果是,消息到达与处理开始之间的时间非常短。
在内核空间中运行弹出式县城通常比用户空间中容易且快捷,而且内核空间中的弹出式线程可以很容易访问所有的表格和I/O设备,这些可能在中断中有用。另一方面,出错的内核线程会比出错的用户线程造成更大的伤害。
9 单线程代码多线程化
一个线程的代码就像进程一样,通常包括多个局部变量,全局变量和过程参数。局部变量和参数不会引起任何问题,但是对线程而言是全局变量,并不对整个程序也是全局的。许多变量之所以是全局的,是因为线程中的很多过程都在使用它们,但是其它县城在逻辑上和这些变量无关。
如图2-19,多个线程使用同一个全局变量会出现问题。对于这个问题有很多解决方案:
- 全面禁止全局变量
- 为每个线程赋予其私有的全局变量。如图2-20,每个线程都有在自己的errno以及其它的全局变量,这样可以避免冲突。
引入新的库过程,以便创建,设置和读取这些线程范围的全局变量。
一个调用如下所示create_global("bufptr");
该调用在堆上或在专门为调用线程所保留的特殊储存区上替一个名为bufptr的指针分配储存空间。只有调用线程才可访问其全局变量,如果另一线程创建了同名的全局变量,由于它在不同的储存单元上,不会与原来的那个变量发生冲突。
将单线程转为多线程的另一个问题:有许多库过程是不可重入的。它们不是设计成下列方式:对于任何给定的过程,当前面的调用尚没有结束之前,可以进行第二次调用。
- 同一块缓冲区,线程1在上面写数据,没写完,此时被时钟切换到线程2,线程2立即重写了该缓冲区。
- 内存分配问题。当malloc的指针不稳定时访问该指针会出现问题。
可以为每个过程提供一个包装器,该包装器设置一个二进制为而标记某个库处于使用中。在先前的调用没有完成之前,任何试图使用该库的其他线程都会被阻塞。
将单线程转为多线程的另一个问题:信号的问题。有些信号逻辑上是线程专用的,有些不是。当线程完全在用户空间实现时,内核不知道有线程存在,很难将信号发送到正确的线程。
将单线程转为多线程的最后一个问题:堆栈的问题。很多系统中,当一个进程的堆栈溢出时,内核只是自动为该进程提供更多的堆栈。当一个进程有多个线程是,就必须有多个堆栈。如果内核不了解所有的堆栈,就不能使他们自动增长,直到堆栈出错。