带你了解Linux中的线程

带你了解Linux中的线程_第1张图片

线程

在传统操作系统中,每个进程有一个地址空间和一个控制线程。事实上这几乎就是进程的定义。不过经常存在在同一个地址空间中准并行运行多个控制线程的情形,这些线程就像分离的进程一样(共享地址空间除外)。

线程的使用

原因:

(1)主要原因:在许多应用中同时发生着多种活动。其中某些活动随着时间的推移会被阻塞。通过将这些应用程序分解成可以准并行运行的多个顺序线程,程序设计模型就会变得很简单。

【只是在有了多线程的概念之后,我们才加入了一种新的元素:并行实体拥有共享同一个地址空间和所有可用数据的能力。】

(2)由于线程比进程更轻量级,所以它们比进城更容易(更快)创建,也更容易撤销。在许多系统中,创建一个线程较创建一个进程要快10~100倍。在有大量线程需要动态和快速修改时,具有这一特性是非常有用的。

(3)有关性能方面,如果多个线程都是CPU密集型的,那么并不能获得性能上的增强,但是如果存在着大量的计算和大量的I/O处理,拥有多个线程允许这些活动彼此重叠进行,从而会加快应用程序执行的速度。

在多CPU系统中,多线程是有益的,在这样的系统中,真正的并行有了实现的可能。

下面我举个例子来说明引入多线程的好处:

假设用户正在写一本书。如果整本书是个文件,那么只要一个命令就可已完成全部的替换处理。相反,如果一本书分成了300个文件,那么就必须分别对每个文件进行编辑。现在考虑,如果有一个用户突然在一个有800页的文件的第一页上删掉了一个语句之后,会发生什么情形。在检查了所修改的页面并确认正确之后,这个用户现在打算接着在第600页上进行进行另一个修改,被强制对整本书的前600页重新进行格式处理,

这是因为在排列该页前面的所有页之前,字处理软件并不知道第600页的第一行应该在哪里。而在第600页的页面可以真正在屏幕上显示出来之前,计算机可能要拖延相当一段时间,从而令用户不甚满意。

这时,多线程可以在这里发挥作用。假设字处理软件被编写成含有两个线程的程序。一个线程与用户交互,而另一个在后台重新进行格式处理。一旦在第一页中的语句被删除掉,交互线程就立即通知格式化线程对整本书重新进行处理。同时,交互线程继续监控键盘和鼠标,并相应诸如滚动第1页之类的简单命令,此刻,另一个线程正在后台疯狂的运算,如果有点运气的话,重新格式化会在用户请求查看第600页之前完成,这样,第600页页面就立即可以在屏幕上显示出来。

我们可以在增加一个线程。许多字处理软件都有每隔若干分钟自动在磁盘上保存整个文件的特点,用于避免由于程序崩溃、系统崩溃或电源故障而造成用户一整天的工作丢失的情况。第三个线程可以用于处理磁盘备份,而不必干扰其他两个进程。

拥有三个线程的情形如下图所示:

有三个线程的字处理软件

很显然,在这里用三个不同的进程是不能工作的,这是因为三个进程都需要对同一个文件进行操作。由于多个线程可以共享公共内存,所以通过用三个线程替代三个进程,使得他们可以访问同一个正在编辑的文件,而三个进程是做不到的。

现在考虑另一个多线程发挥作用的例子:一个万维网服务器。对页面的请求发送给服务器,而所请求的页面发送给客户机。在多数web站点上,某些页面较其他页面相比,有更多的访问。例如,对Sony主页的访问就远远超过对深藏在页面树里的任何特定摄像机的技术说明书页面的访问。利用这一事实,web服务器可以把获得大量访问的页面集合保存在内存中,避免到磁盘去调入这些页面,从而改善性能。这样的一种页面集合称为高速缓存.

一个多线程的web服务器

带你了解Linux中的线程_第2张图片

web服务器的3种设计方案

多线程web服务器:

一种组织web服务器的方式如上图所示,在这里,一个称为分派程序的线程从网络中读入工作请求。在检查请求之后,分派线程挑选一个空转的(即被阻塞的)工作线程,提交请求,通常是在每个线程所配有的某个专门的字中写入一个消息指针。接着分派线程唤醒睡眠的工作线程,将它从阻塞状态转为就绪状态。

在工作线程被唤醒之后,它检查有关的请求是否在web高速页面缓存之中,这个高速缓存是所有线程都可以访问的。如果没有,该线程开始一个从磁盘调入页面的read操作并且阻塞直到该磁盘操作完成。当上述线程阻塞在磁盘操作上时,为了完成更多的工作,分派线程可能挑选另一个线程运行,也可能把另一个当前就绪的工作线程投入运行。

这种模型允许把服务器编写为顺序线程的一个集合。

在分配线程的程序中包含一个无限循环,该循环用来获得工作请求并把工作请求派给工作线程。每个工作线程的代码包含一个从分派线程接受的请求,并且检查web高速缓存中是否存在所需页面的无限循环如果存在,就将该页面返回给客户机,接着该工作线程阻塞,等待一个新的请求。如果没有,工作线程就从磁盘调入该页面,将该页面返回给客户机,然后该工作线程阻塞,等待一个新的请求。

下图给出了有关代码的大致框架(TRUE=1,buf和page分别是保存工作请求和web页面的相应结构):

a)分派线程 b)工作线程

带你了解Linux中的线程_第3张图片

单线程web服务器:

一种可能的方式使其像一个线程一样运行。web服务器的主循环获得请求,检查请求,并且在取下一个请求之前完成整个工作。在等待磁盘操作时,服务器就空转,并且不处理任何到来的其他请求。如果该web服务器运行在唯一的机器上,通常情形都是这样的,那么在等待磁盘操作时CPU只能空转。结果导致每秒钟只有很少的请求被处理。可见线程较好地改善了web服务器的性能,而且每个线程是按通常方式顺序编程的。

有限状态机:

如果可以使用read系统调用的非阻塞版本,还存在第三种可能的设计。在请求到来时,这个唯一的线程对请求进行考察。如果该请求能够在高速缓存中得到满足,那么一切都好,如果不能,则启动一个非阻塞的磁盘操作 。

服务器在表格中记录当前请求的状态,然后去处理下一个事件。下一个事件可能是一个新工作的请求,或是对磁盘先前操作的回答。如果是新工作的请求,就开始工作。如果是磁盘的回答,就从表格中取出对应的消息,并处理该回答,对于非阻塞磁盘I/O而言,这种回答多数会以信号或中断的形式出现。

在这种设计中,每次服务器从为某个请求工作的状态切换到另一个状态时,都必须显示地保存或重新装入相应的计算状态。事实上,我们以一种困难的方式模拟了线程及其堆栈。这里,每个计算都有一个被保存的状态,存在一个会发生且使得相关状态发生改变的事件集合,我们把这类设计称为有限状态机。

下图给出了上述模式的总结:

构造服务器的三种方法

带你了解Linux中的线程_第4张图片

多线程提供了一种解决方案,有关的进程可以用一个输入线程、一个处理线程和一个输出线程构造。输入线程把数据读入到到输入缓冲区中;处理线程从输入缓冲区中取出数据,处理数据,并把结果放到输出缓冲区中;输出线程把这些结果写到磁盘上。按照这种工作方式,输入、处理和输出可以全部同时进行。当然这种模型只有当系统调用只阻塞调用线程而不是阻塞整个进程时,才能正常工作。

经典的线程模型

进程模型基于两种独立的概念:资源分组处理与执行。有时将这两种概念分开会更好,于是引入了“线程”这一概念。

理解进程的一个角度是,用某种方法把相关资源集中在一起。进程有存放程序正文和数据以及其他资源的地址空间。这些资源中包括打开的文件、子进程、即将发生的定时器、信号处理程序、账号信息等。把它们都放到进程中可以更容易管理。

另一个概念是进程拥有一个可以执行的线程,简称为线程。在线程中有一个程序计数器,用来记录接着要执行哪一条指令。线程拥有寄存器,用来保存线程当前的工作变量。线程还拥有一个堆栈用来记录执行历史,其中每一帧保存了一个已调用的但是还没有从中返回的的过程。

尽管线程必须在某个进程中执行,但是线程和它的进程是不同的概念,并且可以分别处理。进程用于把资源集中到一起,而线程则是在CPU上被调度执行的实体。

在同一个线程中并行运行多个进程,是对在同一台计算机上并行运行多个进程的模拟。在前一种情形下,多个线程共享同一个地址空间和其他资源。而在后一种情形中,多个进程共享物理内存、磁盘打印机和其他资源。

由于线程具有进程的某些性质,所以有时被称为“轻量级进程”。

在下图a中可以看到三个传统的进程。每个进程有自己的地址空间和单个控制线程。每个线程都在不同的地址空间中运行。在图b中,可以看到一个进程带有三个控制线程。这三个线程全部在相同的地址空间中运行。

a)三个进程,每个进程有一个线程 b)一个进程带三个线程

带你了解Linux中的线程_第5张图片

进程中的不同线程不像不同进程之间那样存在很大的独立性。所有的线程都有完全一致的地址空间,这就意味着他们也共享同样的全局变量。由于各个线程都可以访问进程地址空间中的每一个内存地址,所以一个线程可以读、写或甚至清除另一个线程的堆栈。线程之间是没有保护的

。原因是:1)不可能 2)也没有必要 。这与不同进程是有差别的。不同的进程会来自不同的用户,它们彼此之间可能有敌意,一个进程总是由某个用户所拥有该用户创建多个线程应该是为了它们之间的合作而不是彼此间争斗。除了共享地址空间外,所有线程还共享同一个打开文件集、子进程、定时器以及相关信号等。

在下图中:第一列给出了在一个进程中所有线程共享的内容,第二列给出了每个线程自己的内容。

带你了解Linux中的线程_第6张图片

线程概念试图实现的是,共享一组资源的的多个线程的执行能力,以便这些线程可以为完成某一任务而共同工作。

和传统进程一样(即只有一个线程的进程),线程可以出于若干种状态的任何一个:运行、阻塞、就绪或终止。线程之间的转换和进程之间的转换是一样的。

每个线程有其自己的堆栈

带你了解Linux中的线程_第7张图片

每个线程都有其自己的堆栈,如上图所示。每个线程的堆栈中有一帧,供各个被调用但是还没有从中返回的过程使用。在该栈帧中存放了相应过程的局部变量以及过程调用完成之后使用的返回地址。

通常每个线程会调用不同的过程,从而有一个各自不同的执行历史,这就是为什么每个线程需要有自己的堆栈的原因。

线程的创建:

在多线程的情况下,进程通常会从当前的单个线程开始。这个线程有能力通过调用一个库函数(如thread_create)创建新的线程。thread_create的参数专门指定了新线程要运行的过程名。这里没有必要对新线程的地址空间加以规定,因为新线程会自动在创建线程的地址空间中运行。有时线程是有层次的,它们具有一种父子关系,但是通常不存在这样一种关系,所有的线程都是平等的。不论有无层次关系,创建线程通常都返回一个线程标识符,该标识符就是新线程的名字。

线程的终止:

当一个线程完成工作后,可以通过调用一个库过程(如thread_exit)退出,该进程接着消失,不再可调度。在某些线程系统中,通过调用一个过程(如:thread_join)一个线程可以等待一个特定的线程退出。这个过程阻塞调用线程直到那个特定线程退出。

thread_yield:

另一个常见的线程调用是thread_yield,它允许线程自动放弃CPU从而让另一个线程运行。这样一个调用是很重要的,因为不同于进程,(线程库)无法利用时针中断强制线程让出CPU,所以设法使线程行为高尚起来并且随着时间的推移自动交出CPU,以便让其他进程有机会运行,就变得非常重要。有的调用允许某个线程等待另一个线程完成某些任务,或等待一个线程宣称它已经完成了有关的工作等。

线程所带来的一些问题:

1).如果父进程拥有多个线程,那么它的子进程也应该拥有这些线程吗?如果不是,则该子进程可能会工作不正常,因为在该子进程中的线程都是绝对必要的。

2).如果子进程拥有了与父进程一样多的线程,如果父进程在read调用上被阻塞了会发生什么情况?

3).还有一类问题和线程共享许多数据结构的事实有关等

POSIX线程

IEEE定义的线程包叫做pthread。大部分UNIX系统都支持该标准。这个标准定义了超过60个系统调用。所有pthread线程都有某些特性。每个都含有一个标识符、一组寄存器(包括 程序计数器)和一组存储在结构中的属性。这些属性包括堆栈大小、调度参数以及其他线程需要的项目。

线程调用                                                                               描述

pthread_create                                                              常见一个新线程

pthread_exit                                                                   结束调用的线程

pthread_join                                                             等待一个特定的线程退出

pthread_yield                                                         释放CPU来运行另一个线程

pthread_attr_init                                                创建并初始化一个线程的属性结构

pthread_attr_destory                                               删除一个线程的属性结构

                               一些pthread的函数调用

创建一个新线程需要使用pthread_create调用。新创建的线程的线程标识符会作为函数值返回。这种调用有意看起来很像fork系统调用,其中线程标识符起着PID的作用,而这么做的目的主要是为了标识在其他调用中引用的线程。

当一个线程完成分配给它的工作后,可以通过调用pthread_exit来终止,这个调用终止线程并释放它的栈。

像pthread_join、pthread_yield的作用前面已经介绍了。

最下面的两个线程调用是处理属性的。pthread_attr_init建立关联一个线程的属性结构并初始化成默认值。这些值(例如优先级)可以通过修改属性结构中的域值来改变。

pthread_attr_destory删除一个线程的属性结构,释放它占用的内存。它不会影响调用它的线程。这些线程会继续存在。

为了更好地了解pthread是如何工作的,考虑下面的例子。

#include

#include

#include

#define NUMBER_OF_THREADS  10

void *print_hello_world(void *tid)

{

    /*本函数输出线程的标识符,然后退出。 */

    printf("Hello World.Greetings from thread %d\n",tid);

    pthread_exit(NULL);

}

int main(int argc,char *argv[])

{

    /*主程序创建10个线程然后退出。 */

    pthread_t threads[NUMBER_OF_THREADS];

    int status,i;

    for(i=0;i

        printf("Main here,Create thread %d\n");

        status = pthread_create(&threads[i],NULL,print_hello_world),(void *)i);

        if(status != 0){

            printf("Oops.pthread_create returned error code %d\n",status);

            exit(-1);

        }

    }

    exit(NULL);

}

这里主程序在宣布它的意图之后,循环NUMBER_OF_THREADS次,每次创建一个新的线程。如果线程创建失败,会打印出一条错误信息然后退出。在创建完所有线程之后,主程序退出。当创建一个线程时,它打印一条一行的发布信息,然后退出。这些不同信息交错的顺序是不确定的并且可能在连续运行程序的情况下发生变化。

在用户空间中实现线程

把整个线程包放在用户空间中,内核对线程一无所知。从内核角度考虑,就是按正常的方式管理,即单线程进程。(即进程表在内核中,线程表在用户空间中。)

在用户空间中实现线程的优点:

(1).用户级线程包可以在不支持线程的操作系统上实现。通过这一方法,可以使用函数库实现线程。所有的这类实现都有同样的通用结构。如图一(a)所示,线程在一个运行时系统的上层运行,该运行时系统是一个管理线程的过程的集合。

图一(a)用户级线程包 (b)由内核管理的线程包

带你了解Linux中的线程_第8张图片
图一(a)用户级线程包 (b)由内核管理的线程包

在用户管理线程时每个进程需要有其专用的线程表,用来跟踪该进程中的线程。这些表和内核中的进程表类似,不过它们仅仅记录各个线程的属性,如每个线程的程序计数器、堆栈指针、寄存器和状态等。该线程表由运行时系统管理,当一个线程转换到就绪状态或阻塞状态时,在该线程表中存放重新启动该线程所需的信息,与内核在进程表中存放进程的信息完全一样。

(2).进行类似于这样的线程切换至少比陷入内核要快一个数量级(或许更多),这是使用用户级线程包的极大的优点。

(3).保存该线程状态的过程和调度程序都只是本地过程,所以启动它们比进行内核调用效率更高。

(4).不需要陷入内核,不需要上下文切换,也不需要对内存高速缓存进行刷新,这就使得线程调度非常快捷。

(5).它允许每个线程有自己的调度算法。

(6).具有较好的可扩展性,这是因为在内核空间中内核线程需要一些固定表格空间和堆栈空间,如果内核线程的数量非常大,就会出现问题。

在用户空间中实现线程的问题:

(1).如何实现阻塞调用使用线程的一个主要目标是,首先要允许每个线程使用阻塞调用,但是还要避免被阻塞的线程影响其他的线程。有了阻塞系统调用,这个目标不是轻易能够实现的。

对于上述问题的解决:1. 系统调用可以全部改成非阻塞的,但是这需要修改操作系统 2. 如果某个调用会阻塞,就会提前通知。其过程为首先进行select调用,然后只有在安全的情形下(即不会阻塞)才进行read调用。如果read调用会被阻塞,有关的调用就不进行,代之运行另一个线程。到了下次有关的运行系统取得控制权之后,就可以再次检查看看现在进行read调用是否安全。在系统调用周围从事检查的这类代码称为包装器。

(2).缺页中断问题,如果某个程序调用或者跳转到了一条不在内存的指令上,就会发生页面故障,而操作系统将到磁盘上取回这个丢失的指令(和该指令的“邻居们 ”),这就称为“页面故障”。

(3).如果一个线程开始运行那么在该进程中的其他线程就不能运行,除非第一个线程自动调用CPU。

其可能的解决方案是:让运行时系统请求每秒一次的时钟信号(中断),但是这样对程序是生硬的和无序的。不可能总是高频率地发生周期性的时钟中断,即使可能总的开销也是可观的。而且线程也可能需要时钟中断,这就会扰乱运行时系统使用的时钟。

(4).程序员通常在经常发生线程阻塞的应用中才希望使用多个线程。对于那些基本上是CPU密集型(一些进程绝大多数时间在计算上,称为计算密集型(也称CPU密集型))而且极少有阻塞的应用程序而言,就没必要使用多线程了。

在内核中实现线程

不需要运行时系统,而且每个进程中也没有线程表。在内核中有用来记录系统中所有线程的线程表。当某个线程希望创建一个新线程或撤销一个已有线程时,它进行一个系统调用,这个系统调用通过对线程表的更新完成线程创建或撤销工作。(即进程表和线程表都在内核中)如图一(b)所示。

(1).所有能够阻塞线程的操作都以系统调用的形式实现,这与运行时系统过程相比,代价是相当客观的。当一个线程阻塞时,内核根据其选择,可以运行同一个进程中的另一个线程(若有一个就绪的线程)或者运行另一个进程的线程。而在用户级线程中,运行时系统始终运行自己进程中的线程,直到内核剥夺它的CPU(或者没有可运行的线程存在了)为止。

(2).在内核中创建或撤销线程的代价比较大。可以采用回收线程的方法解决:当某个线程被撤销时,就把它标识为不可运行,但是其内核数据没有受到影响。稍后再创建一个新线程时,就重新启动某个旧线程,从而节省开销。

(3).内核线程不需要任何新的、非阻塞系统调用。这样做的缺点是系统调用的代价比较大,所以如果线程的操作(创建、终止等)比较多,就会造成很大的开销。

使用内核线程带来的问题:1. 当一个多线程进程创建新的进程时会发生什么? 2. 如果两个或多个线程注册了相同的信号,会发生什么? 等等。

混合实现

一种方法是使用内核级线程,然后将用户级线程与某些或者全部内核线程多路复用起来。如下图所示。

用户级线程与内核线程多路复用

带你了解Linux中的线程_第9张图片
用户级线程与内核线程多路复用

优点:编程人员可以决定用多少个内核级线程和多少个用户级线程彼此多路复用。这一模型带来最大的灵活度。

采用这种方法,内核只识别内核级线程,并对其进行调度。其中一些内核级线程会被多个用户级线程多路复用。如同在没有多线程能力操作系统中某个进程中的用户级线程一样,可以创建、撤销和调度这些用户级线程。在这种模型中,每个内核级线程有一个可以轮流使用的用户级线程集合。

调度程序激活机制

调度程序激活工作的目标是模拟内核线程的功能,但是为线程包提供通常在用户空间中才能实现的更好的性能和更大的灵活性。特别地,如果用户线程从事某种系统调用时是安全的,那就不应该进行专门的非阻塞调用或者进行提前检查。无论如何,如果线程阻塞在某个系统调用或页面故障上,只要在同一个进程中有任何就绪的线程,就应该有可能运行其他的线程。

由于其避免了在用户空间和内核空间之间的不必要转换,从而提高了效率。

当时使用调度程序激活机制时,内核给每个进程安排一定数量的虚拟处理器,并让(用户空间)运行时系统将线程分配到处理器上。这一机制也可以用在多处理器中,此时虚拟处理器可能成为真实的CPU。分配给一个进程的虚拟处理器的初始数量是一个,但是该进程可以申请更多的处理器并且在不用时退回。

使该机制工作的基本思路是当内核了解到一个线程被阻塞之后(例如由于执行了一个阻塞系统调用或者产生了一个页面故障),内核通知该进程的运行时系统,并且在堆栈中以参数形式传递有问题的线程编号和所发生事件的一个描述。内核通过在一个已知的起始地址启动运行时系统,从而发出了通知,这是对UNIX中信号的一种粗略模拟。这个机制称为上行调用。

调度程序激活机制的一个目标是作为上行调用的信赖基础,这是一种违反分层次系统内在结构的概念。通常,n层提供n+1层可调用的特定服务,但是n层不能调用n+1层中的过程。上行调用并不遵守这个基本原理。

弹出式线程

一个消息的到达导致系统创建一个处理该消息的线程,这种线程称为弹出式线程。

弹出式线程的关键好处是:由于这种线程相当新,没有历史————没有必须储存的寄存器、堆栈诸如此类的内容,每个线程从全新开始,每一个线程彼此之间都完全一样。这样,就有可能快速创建这类线程。对该新线程指定所要处理的信息。使用弹出式线程的结果是,消息到达与处理开始之间的时间非常短。

在使用弹出式线程之前,需要提前进行计划。例如:哪个进程中的线程先运行?如果系统支持在内核上下文中运行线程,线程就有可能在那里运行(这就是下图中没有画出内核的原因。)

带你了解Linux中的线程_第10张图片

优点:

在内核空间中运行弹出式线程通常比在用户空间中容易且快捷,而且内核空间中的弹出式线程可以很容易访问所有的表格和I/O设备。这也许在中断处理时有用。

缺点:

出错的内核线程会比出错的用户线程造成更大的损害。例如,如果某个线程运行时间太长,又没有办法抢占它,就可能造成进来的信息丢失。

使单线程代码多线程化

许多已有的程序是为单线程进程编写的。把这些程序改写成多线程需要比直接写多线程程序更高的技巧。

考虑一个例子,考虑由UNIX维护的errno变量。当进程或(线程)进行系统调用失败时,错误码会放入errno。在下图中,线程1执行系统调用access以确定是否允许它访问某个特定文件。操作系统把返回值放到全局变量errno中。当控制权返回到线程1之后,并在线程1读取errno之前,调度程序确认线程1此刻已用完CPU时间,并决定切换到线程2.线程2执行一个open调用,结果失败,导致重写errno,于是给线程1的返回值会永远丢失。随后在线程1执行时,它将读取错误的返回值并导致错误操作。

线程使用全局变量所引起的错误

带你了解Linux中的线程_第11张图片
线程使用全局变量所引起的错误

对于上面问题已有各种解决方案。

(1)全面禁止全局变量,但它同许多已有的软件冲突。

(2)为每个线程赋予其私有的全局变量如下图所示:

线程可拥有私有的全局变量

带你了解Linux中的线程_第12张图片
线程可拥有私有的全局变量

在这个方案中,每个线程有自己的errno以及其他全局变量的私有副本,这样就避免了冲突。在效果上,这个个方案创建了新的作用域层,这些变量对一个线程中所有过程都是可见的。而在原先的作用层域中变量值对一个过程可见,并在程序中处处可见。

(3)引入新的库过程,以便创建、设置和读取这些线程范围的全局变量。首先一个调用也许是这样的:create_global(“bufptr”);该调用在堆上或在专门为调用线程所保留的特殊存储区上替一个名为bufptr的指针分配存储空间。无论该存储空间分配在何处,只有调用线程才可访问其全局变量。如果另一个线程创建了同名的全局变量,由于他在不同的存储单元上,所以不会与已有的那个变量产生冲突。

(4)为每个过程提供一个包装器,该包装器设置一个二进制位从而标志某个库处于使用中。在先前的调用还没有完成之前,任何试图使用该库的其他线程都会被阻塞。尽管这个方式可以工作,但是它会极大地降低系统潜在的并行性。

你可能感兴趣的:(带你了解Linux中的线程)