Coding Poet, Coding Science

进程原理三:线程原理与编程、调度器

线程的相关原理

线程的基本概念

这里所讲的线程都是用户所使用的线程,不说系统内核中存在的诸多线程。

一般来说,linux的进程已经是轻量的了,创建多个进程有时候比多个线程更容易进行协调。而使用线程库是在用户空间进行的,存在效率和复杂度的问题。

在linux下线程的实现是按照POSIX线程的规范,POSIX线程可以在内核的支持下实现,也可以在用记线程库下实现,对于linux来说,2.6版本以上的POSIX线程的相关接口最终被放到内核中实现。

在讲到进程是有提到clone函数,现在来看,这个函数相当于一个底层的接口,和POSIX提供的相关接口不在同样的层次上。因此我们只讲POSIX的线程接口。

为了防止主题分化。下面讲一般的线程的原理

线程分为用户级线程ULT和内核级线程KLT.用户级线程在操作系统看来是一个进程当中的一部分代码,和同一进程中的其它线程不可分辨。

实际中发现线程的调用很成问题啊,首先得到的两个线程PID一样的,然后貌似sleep的时间也不对。

这种混淆主要是线程比较晚出现,导致原来调度器使用的进程概念被换成线程会使系统对外的接口发生很大的变化,因而带来不便。

线程的核心我们定义为方便地共享数据,一切优点和缺点都是围绕着共享数据来进行的。

实现的一个线程只需要最基本的调度标识符,进程控制块,其它的资源都由主进程所提供。它们共享其中的资源。既然如此,也就能够通过对共享数据的读写实现线程同步,这要比进程的同步容易一些。

相应地,实现上难了,但对于开发者来说却在某种程度上容易了,所以线程还是对于程序员来说不着不小的意义。

线程的切换在操作系统看来也要比进程切换容易,因为不需要把所有的上下文都保存。

线程的一个积极意义吧,就是在多内核环境下的并行要容易一些。进程在多核环境下实现并行,最关键的如文件的共享,得改变文件缓存的机制。但是线程共享的数据结构相对来说比较高级,比如共享文件描述符,显然比共享一磁盘块本身要简单。

这里的难的容易的问题是从要设计一个操作系统的解度来说的,这个时候还没有一个现成的操作系统可以使用,但是对于学习者来讲,容易和困难缺乏意义,因为一个实现已经存在了。已经做到的事情,谁会说它很困难呢,至多可称得上复杂。

线程在一个复杂环境中特别有意义,因为程序中有的代码负责输入,有的负责输出,有的则是自行处理数据。如果让进程实现就不得不阻塞很久,所以这种场合恐怕是最适合多线程程序的。一个重要的方面就是网络应用。

个人觉得操作系统原理应该说到网络环境下线程有重要的应用。

要创建线程与创建进程表面上的不同是,创建进程可以在没有相关数据的情况下进行,但是创建线程却要在一个已有的环境下创建。这导致一个错误的认识,就是线程看起来是如此轻量,以致于很难想象还需要什么额外的工作。实际编程实现的时候,线程可以作为一个函数来执行。也就是说,一个线程负责运行特定的函数。在多线程程序中设计流程是非常困难的。Alan Cox 曾评论线程为“如何立刻让自己自讨苦吃。”

linux下线程的实现

在linux下线程库的接口是一样的,在不同版本被实现为用户级线程或者内核级线程。其中的主要道理是,对于应用程序来说,区分用户级线程或者内核级线程没有多大的意义。在linux下查询线程是如何实现的,可使用命令getconf GNU_LIBRPTHREAD_VERSION查看使用的线程库的版本,如果是NPTL表示使用的是内核级的线程库。在linux当中原来的线程库被称为LinuxThread,它是用户级的线程库。

在linux之下,共享的资源有各种类型,比如环境变量的共享也是单独的一个部分,新创建的进程也可以共享其FS,但是按照线程的定义,代码段,数据区都应该是共享的。操作系统在实现的时候,要将PCB中的相应指针指向内存中的同一个页面。

讲进程的时候提到clone()函数,就当它不存在吧。也别管它是什么内核级线程的创建函数,实际中已经有一个高级库了,用不着再从底层做起。

很重要的一点,使用NPTL线程库创建的线程,PID和主线程的一样,但是TID和主进程的不同。因此它们仍然共享一个task_struct结构,所以多线程程序和单线程程序的不同之处,仅在于多线程程序当中的task_struct结构里的线程队列链里有多个成员。创建新的线程的时候,只是相当于在task_struct 当中注册了新的线程栈。

由此引起的问题是,调度时的单位是不是一个PID,因为使用NPTL内核级的线程库的时候,仍然是同一PID.所以在2.6之前的调度实体都是进程。

NPTL的设计是仍然被视为同一进程,且仍然使用clone()系统调用。不过其中也运用了内核当中的特殊支持。NPTL是\(1*1\)的线程库,使用一个pthread_create()就在内核当中创建了一个调度实体。一般来说,存在着\(m*n\)的调度,这种情况下用户线程数大于内核调度实体的数目。

这里涉及一大堆的概念。其一,我们称linux的进程为轻量进程,是因为不同的PID共享了相同的空间。而这部分仍是传统的FORK调用。进程与线程的详细情况,见下面的调度器笔记。

linux下的线程使用的就是符合POSIX的线程库,要在程序当中使用线程,使用头文件pthead.h.

使用线程的基本要求是不要使用进程的退出函数,这样会导致整个物理内存上的相关数据结构都被删除。因而带来不安全。

线程库在linux下也可以看成是一个封装,其中含有相应的,类似fork这样分配PID的代码。

一个系统中线程的数量参考:linuxThread线程库中线程数是在编译时就指定的,而NPTL支持动态的线程数,在一个IA-32系统上可达两百万个。关于线程的速度,则是如果使用NPTL,启动100000个进程只需2秒,而不使用NPTL需要15min.

POSIX线程库中创建线程的方法,以C语言为例

创建一个线程的函数原型是:

int pthread_create(pthread_t *thread, pthread_attr_t *attr,
void *(*start_routine)(void *), void *arg);

终止当前线程的函数原型是:

int pthread_exit(void *retval);

看起来 pthread_create 函数使用了一大堆的指针,其实却很简单,返回值是否为零代表成功或者失败。第一个参数相当于线程控制块,第二个是线程的属性,第三个是线程的入口函数,第四个是需要传递给该函数的参数。

UNIX函数的惯例是调用失败时返回-1,比如对于前面到的fork函数。0代表对函数来说有特殊含义的一个量,-1才代表一个调用错误,函数内部的代码无法完成所称的工作。

函数要写成 void *function(void *arg) 的形式,以便正确地进行转换。下面是一个例子。可以和fork()相对比。

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <errno.h>
#include <string.h>
int main(){
int ret;
pthread_t mythread;
ret = pthread_create(&mythread, NULL, myThread, NULL);
if (ret != 0){
    printf("Cannot create thread (%s)\n", strerror(errno));
    exit(-1);
}
return 0;
}
void *myThread(void *arg){
printf("Thread is running.\n");
pthread_exit(arg);
}

以上代码的重要之处如下:

  1. 返回值0代表线程正常创建了;
  2. 当调用系统提供的函数出现错误的时候,建议使用strerror,这样一来错误会发到stderr设备。而且还会提供人人都能理解的错误信息。
  3. 线程函数中退出不能使用exit(),而是用 pthread_exit() 并且参数中的数返回的是函数的退出代码。关键是这个退出号不能是一个局部变量,因为退出函数时候变量会被销毁导致错误号无法正常传出去。使用arg作为返回时的位置,是一个避免在主线程中重新定义一个变量的技巧。
  4. 编译时,首先需要定义宏 _REENTRANT ,在有的系统上(不常见)还要定义 _POSIX_C_SOURCE 宏。如果系统默入不是用NPTL实现的,编译选项为:
cc -D _REENTRANT -I/usr/include/nptl thread1.c -o thread1 
-L/usr/lib/nptl -lpthread

如果默认已经是用NPTL实现的,就使用

cc -D_REENTRANT thread1.c -o thread1 -lpthread
  1. 因此我们在编程上也知道了,函数使用exit()退出,return退出,还是代码执行完毕后自动退出,是有区别的。

POSIX线程的基本函数

父线程使用下面的函数等待子线程的终止,原型如下:

int pthread_join(pthread_t thread, void ** status);

第一个参数标明要等待的子线程.线程的资源在父线程调用 pthread_join之后释放,相当于在进程当中子进程的资源在父进程调用wait()之后释放.

pthread_self() 函数返回当前线程的标识.

内核线程

在内核引导和启动的时候,诸多的内核功能模块也会被使用,它们的地址空间是整个实地址空间,因而互相能够访问到,syslogd就是其中的一个。在ps命令下我们看到它是一个不同PID的进程结构。所以内核线程大致是以轻量进程的方式实现的。(猜测而已)

linux的调度器

分类的必要性

在操作系统当中,将调度器的时候都是在讲完进程之后,讲线程之前,但是带线程的调度器的进程调度器的不同之处太多了。

进程调度的时候遵循调度的一般原理,一般来说,关键在于掌握调度算法,知道怎样给一个一个的调度实体分配CPU.但是调度器的工作实际上不只这些,它还要区分哪些是调度实体,把调度实体的相关代码和数据加载到适合于执行的位置。

原理中那样讲的不好之处是,如果是用户级线程,就认为内核没有变化,如果是内核级线程,就把进程的区域交给其它线程共享。

所以认为传统进程是一个单线程的“进程”,这种认识是肤浅的。下面将会指出实际情况。

带线程的进程模型

当一个程序不带有额外的线程的时候,生成的进程的结构和传统进程一样,包括文本段,数据段。但是一个程序如果带有线程的话,就变得不同了。这种情况下,会多出一个线程栈。这样以来就会使得对外的表现仍然是,在加载程序的时候,整个代码都会被加载以形成一个调度的实体。这样的进程和传统进程是一样的。而这时的调度器,仍然以整个进程作为调度单位。如果进程中有多余的线程,是通过KLT实现的(这里我们不能称线程为内核线程,因为这个词已经有过了定义)。那么KLT的库负责的任务就是在程序有创建线程的需求的时候利用task_struct结构当中对线程的支持,分配一个线程栈,并告诉调度器这个线程栈是一个独立的实体。可以直接在其中执行代码的。

在这一过程当中,编译器并不会感知到线程栈的存在,它仍然正常地生成有关函数的代码。当编译和连接按照流程走一遍之后,就生成了可执行文件。当程序加载执行的时候,运行到创建线程的时候,如果是KLT,就会实际上把传给它的函数的入口地址一直到结尾的部分分配到线程栈当中。告诉给调度器的也就是函数的入口地址和相关参数。因此我们认为,在KLT之下,一个线程就是一个单独运行的函数,这个函数的参数也是由线程库给出的,做完这些,函数代码就可以独立执行了,因此对于调度器来说,KLT线程是非常短小精悍的一段代码。

我们这里的程序的含义是由源代码生成的一个文件。

接下来讲ULT.虽然ULT不与调度器没有什么关系,不过对于程序执行还是有着影响,那就意味着会产生一个问题,如何让一个进程,一会儿执行这个线程上的代码,一会儿执行那个线程上的代码。

对于LinuxThread这样的库来说,实现多线程的方式估计很多人都要泪流满面了。实际对于LinuxThread最基本的是clone()出来的多个进程。

clone()函数有多个功能,其中一个最核心的功能,最准确的描述是产生一个新的进程,这个进程的地址空间始终和父进程相同。

LinuxThread库的功能主要集中在使用信号量在不同进程之间进行同步,并借此达到实现线程的目的。

以下就是澄清了的事实:

clone()函数有多个功能,借助它可以实现用户级的线程库(LinuxThread),也可以实现内核级的线程库(NPTL)。如果是使用NPTL,就要做参数上的调整。这就是为什么在NPTL的介绍当中,单独把“使用clone()”作为一句话的原因,其言外之意,原来的clone()是用来实现用户级线程库的。

有句话叫“在系统看来,用户级线程相当于一个进程,内核级线程相当于多个进程”。这句话是很有误导性的。准确的意思是,1.在调度程序看来,用户级线程之间共享一个时间片。分配时间的时候是一起的。但是在调度程序看来,无论是用户级线程,还是内核级线程,它们都被调度程序作为排队的不同成员。2.在操作系统加上线程库看来,用户级线程才相当于一个单位。

解释其一,在Linux程序设计当中有这样的一句话,第417页上说,“…许多项目都在研究如何才能改善Linux对线程的支持,…,其中大部分工作都集中在如何将用户级的线程映射到内核级的线程。”。后面的词“映射”非比寻常。因此原来的线程库当中,是通过线程库把一个线程映射到不同的进程当中,线程库所做的工作就是如何调整不同进程之间的同步关系,使得在一个传统的操作系统下实现线程。因此在操作系统看来,这样的每一个线程都是一个进程,所以它们可以分别被调度。至于时间片的分配,只要在创建进程的时候,使得两个task_struct共享一个时间值,这样以来就可以在不共享PID的同时实现时间片的共享,因此是倒回到模拟实现去了。这样外在表现就好像是一个进程一样。

解释其二,我们不难理解用户级线程慢的原因了,这倒不是说自己分配数据区很慢,而是说,实际上需要创建多个“病态的”进程结构。还可以知道,用户级线程容纳的线程数是非常有限的,因为系统容纳的进程数往往不是很多。在这样的系统当中实现多线程,潜能就不是很大,因为一个用户级线程被映射到一个操作系统进程。而内核级线程库的优势就在于在不改变可容纳进程数的前提下,仅通过调整可调度单位数,就能够创建出大量的线程来,并且不占用操作系统的PID资源。

线程库和操作系统的关系需要一番思考。如果不对操作系统进行修改,操作系统的调度单位仍是一个一个的进程,线程库能做的,也只能是把程序分成多个进程,这样才能实现并行。当然,在支持KLT的系统中也可以设计出更复杂的用户级线程库。m:n的线程模型就是一个例子。

因此说,不用修改操作系统,单靠线程库就可以实现用户线程也是不恰当的,如果非要那么做,线程的效率要低于普通的进程,所以运行效率上:

内核线程\(<\)单个进程\(<\)内核线程之和\(<\)轻量进程上实现的用户线程\(<\)多个进程\(<\)单个进程加上同步形成的线程。但是从实现复杂度上,进程比轻量进程易,轻量进程比线程易,KLT比ULT易。

摘自《Linux操作系统实验教程》:linux内核支持的用户线程都有一个用来维护它的task_struct 结构,该用户线程实质上是一个蛤备单独 task_struct结构的子进程。

调度的本质

以前总不能避免把一个 task_struct 看成一个调度单位,所以就认为一个结构就代表一个进程,所以ULT就是在一个进程当中,一个KLT就是占好几个 task_struct .恰恰和事实相反。

轻量进程和内核线程应该算是实现线程的不同思路。前者的地位,相当于在一个传统的操作系统当中,不适合共享数据,内核从而进行机制上的改造,使之提高创建进程的速度,如果操作系统不这么做,用户级线程也不可能在linux上出现的那么早。

因此我对内核线程的态度大为改善,以前以为它是通过创建大量的进程实现的。而这个认识被证明是完全错误的。对不起,内核线程,KLT,亲爱的NPLT.

调度的本质应当是一段代码及其数据在处理器和内存之间的移动,因此调度的对象完全不必是一个进程。这点原理书上说的是正确的。但是一个操作系统必须有确定的调度对象,这个也是无法改变的。如果我们以调度的最本质的特点来看操作系统,就知道调度的思想来自于甚至还未有操作系统的时代。只是在操作系统发展的初期,只能以进程为完整单位进行调度,所以在操作系统这时的调度反而是最不灵活的。

我们重新定义调度的对象,不再以进程作为根据。所以我们在线程调度的设计当中,更进一步了解了调度的实质。细化了调度的粒度。

所以在linux中通过调整nice值改变进程优先级的意义对于内核级线程作用就很小了,而且也不是人们所期望的改变。所以可能有必要设计一个工具函数,能够细化到对进程内部的线程调整优先级。

Linux的进程与作业管理

操作系统原理的时候从来没有讲过作业是怎么一回事,只讲到了进程及其相关的调度,现在我们就把要学的内容完整起来。

Linux是一个多任务的操作系统。在计算机看来,系统上同时运行着多个进程。正在执行的一个或者多个相关的进程称为一个作业。使用作业控制,用户便可以同时运行多个作业,并在需要时于不同作业之间切换。

Linux是一个多用户多任务的操作系统。多用户指多个用户可以同一时间使用计算机系统,多任务指的是可以同时执行几个任务。由操作系统负责管理多个用户的请求与多个任务。

最关键的地方还是这些用户请求与任务直接由操作系统所识别。

任务这个词比进程还要更基本一些,不只是传统的进程,也包括了用户作业,操作系统任务,邮件与打印作业等。它们出现的形式可以有多种多样的。这每一个单位称为一个任务。不过在Linux下面,所有运行的东西都可以被称为一个进程(调度的单位现在是线程了)。用户任务以及守护进程都可以称为进程。

对进程比较正式的定义是,在自身的虚拟地址空间运行的一个单独的程序。Linux下面进程有三种类型,交互进程,批处理进程以及守护进程。三种进程各有各的作用,使用的场合也有所不同。

一个正在执行的进程称为一个作业,然而一个作业可以包括一个或者多个命令,特别是在我们使用管道与重定向命令的时候。比如在Shell输入一串带有管道命令的命令,就执行了一个作业,然而有多个进程。

作业控制指的是控制作业中正在运行的进程的行为,如控制作业的挂起或者暂停。许多Shell都有作业控制的特性,使用户可以在多个独立作业之间切换。

一般而言,只有与作业控制相关联的进程才能被称为作业,不受作业控制的进程通常不划入作业的范围。大多数情况下,用户一个Shell只运行一个作业。

进程一般有手动启动与调度启动两种方法。手动启动又有前台启动与后台启动之分。如果使用了管道,管道里面的各个进程是同时启动的。控制进程的启动,可以使用at,batch或者cron工具,而管理当前Shell里面的作业,可以使用信号,bg与fg.