首页 > 代码库 > Linux线程学习

Linux线程学习

线程基础

进程

  系统中程序执行和资源分配的基本单位
  每个进程有自己的数据段、代码段和堆栈段
  在进行切换时需要有比较复杂的上下文切换
 
线程
  减少处理机的空转时间,支持多处理器以及减少上下文切换开销, 比创建进程小很多
  进程内独立的一条运行路线
  处理器调度的最小单元,也称为轻量级进程

  可以对进程的内存空间和资源进行访问,并与同一进程中的其他线程共享

  线程相关的执行状态和存储变量放在线程控制表
  一个进程可以有多个线程,有多个线程控制表及堆栈寄存器,共享一个用户地址空间
 
多线程同步问题
  线程共享进程的资源和地址空间
  任何线程对系统资源的操作都会给其他线程带来影响
 

 
线程技术发展
  Linux 2.2内核
    •不存在真正意义上的线程
  Linux 2 .4内核
    •消除线程个数的限制,允许动态地调整进程数上限
  在Linux 内核2.6之前,进程是最主要的处理调度单元,并没支持内核线程机制
  Linux 2.6内核
    •实现共享地址空间的进程机制, 在1996年第一次获得线程的支持
 
线程技术发展
  为了改善LinuxThread问题,根据新内核机制重新编写线程库, 改善Linux对线程的支持
    •由IBM主导的新一代POSIX线程库(Next Generation POSIX Threads,简称为NGPT)
      –NGPT项目在2002年启动
      –为了避免出现有多个Linux线程标准,在2003年停止该项目
    •由Red Hat主导的本地化POSIX线程库 (Native POSIX Thread Library,简称为NTPL)
      –最早在Red Hat Linux9中被支持
      –现在已经成为GNU C函数库的一部分,同时也成为Linux线程的标准
 
 
 
线程标识
  线程ID
    •进程ID在整个系统中是唯一的
    •线程ID只在它所属的进程环境中有效
函数: pthread_self()

线程标识
  pthread_t类型通常用结构来表示
  •不能把它作为整数处理
    –Linux使用无符号长整数表示
  •为了移植,使用函数来比较线程ID
函数: pthread_equal()

 1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <pthread.h>
4
5 int main(){
6 pthread_t thread_id;
7
8 thread_id=pthread_self(); // 返回调用线程的线程ID
9 printf("Thread ID: %lu.\n",thread_id);
10
11 if (pthread_equal(thread_id,pthread_self())) {
12 // if (thread_id==0) {
13 printf("Equal!\n");
14 } else {
15 printf("Not equal!\n");
16 }
17 return 0;
18 }

 

 

线程编程
  操作用户空间中的线程
 
创建线程
  •调用该线程函数的入口点
  •使用函数pthread_create(),线程创建后,就开始运行相关的线程函数
 1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <pthread.h>
4
5 void *thrd_func(void *arg);
6 pthread_t tid;
7
8 int main(){
9 // 创建线程tid,且线程函数由thrd_func指向,是thrd_func的入口点,即马上执行此线程函数
10 if (pthread_create(&tid,NULL,thrd_func,NULL)!=0) {
11 printf("Create thread error!\n");
12 exit(1);
13 }
14
15 printf("TID in pthread_create function: %u.\n",tid);
16 printf("Main process: PID: %d,TID: %u.\n",getpid(),pthread_self());
17
18 sleep(1); //race
19
20 return 0;
21 }
22
23 void *thrd_func(void *arg){
24 // printf("I am new thread!\n");
25 printf("New process: PID: %d,TID: %u.\n",getpid(),pthread_self()); //why pthread_self
26 printf("New process: PID: %d,TID: %u.\n",getpid(),tid); //why pthread_self
27
28 pthread_exit(NULL); //退出线程
29 // return ((void *)0);
30 }

 
退出线程
  •在线程函数运行完后,该线程也就退出了
  •或使用函数pthread_exit(),这是线程的主动行为
  •不能使用exit()

使调用进程终止,所有线程都终止了

等待线程

  •由于一个进程中的多个线程是共享数据段的,通常在线程退出之后,退出线程所占用的资源并不会随着线程的终止而得到释放

  •pthread_join()函数

    类似进程的wait()/waitpid()函数,用于将当前线程挂起来等待线程的结束
    是一个线程阻塞的函数,调用它的线程一直等待到被等待的线程结束为止
    函数返回时,被等待线程的资源就被收回

 1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <pthread.h>
4
5 void *thrd_func1(void *arg);
6 void *thrd_func2(void *arg);
7
8 int main(){
9 pthread_t tid1,tid2;
10 void *tret;
11 // 创建线程tid1,线程函数thrd_func1
12 if (pthread_create(&tid1,NULL,thrd_func1,NULL)!=0) {
13 printf("Create thread 1 error!\n");
14 exit(1);
15 }
16 // 创建线程tid2,线程函数thrd_func2
17 if (pthread_create(&tid2,NULL,thrd_func2,NULL)!=0) {
18 printf("Create thread 2 error!\n");
19 exit(1);
20 }
21 // 等待线程tid1结束,线程函数返回值放在tret中
22 if (pthread_join(tid1,&tret)!=0){
23 printf("Join thread 1 error!\n");
24 exit(1);
25 }
26
27 printf("Thread 1 exit code: %d.\n",(int *)tret);
28 // 等待tid2结束,线程函数返回值放在tret中
29 if (pthread_join(tid2,&tret)!=0){
30 printf("Join thread 2 error!\n");
31 exit(1);
32 }
33
34 printf("Thread 2 exit code: %d.\n",(int *)tret);
35
36 return 0;
37 }
38
39 void *thrd_func1(void *arg){
40 printf("Thread 1 returning!\n");
41 // sleep(3);
42 return ((void *)1); // 自动退出线程
43 }
44
45 void *thrd_func2(void *arg){
46 printf("Thread 2 exiting!\n");
47 pthread_exit((void *)2); // 线程主动退出,返回(void *)2
48 }



取消线程

  •在别的线程中要终止另一个线程
  •pthread_cancel()函数
  •被取消的线程可以设置自己的取消状态
    –被取消的线程接收到另一个线程的取消请求之后,是接受还是忽略这个请求
    –如果接受,是立刻进行终止操作还是等待某个函数的调用等

 1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <pthread.h>
4
5 void *thrd_func1(void *arg);
6 void *thrd_func2(void *arg);
7
8 pthread_t tid1,tid2;
9
10 int main(){
11 // 创建线程tid1,线程函数thrd_func1
12 if (pthread_create(&tid1,NULL,thrd_func1,NULL)!=0) {
13 printf("Create thread 1 error!\n");
14 exit(1);
15 }
16 // 创建线程tid2,线程函数thrd_func2
17 if (pthread_create(&tid2,NULL,thrd_func2,NULL)!=0) {
18 printf("Create thread 2 error!\n");
19 exit(1);
20 }
21 // 等待线程tid1退出
22 if (pthread_join(tid1,NULL)!=0){
23 printf("Join thread 1 error!\n");
24 exit(1);
25 }else
26 printf("Thread 1 Joined!\n");
27 // 等待线程tid2退出
28 if (pthread_join(tid2,NULL)!=0){
29 printf("Join thread 2 error!\n");
30 exit(1);
31 }else
32 printf("Thread 2 Joined!\n");
33
34 return 0;
35 }
36
37 void *thrd_func1(void *arg){
38 // pthread_setcancelstate(PTHREAD_CANCEL_DISABLE,NULL);
39 pthread_setcancelstate(PTHREAD_CANCEL_ENABLE,NULL); // 设置其他线程可以cancel掉此线程
40
41 while(1) {
42 printf("Thread 1 is running!\n");
43 sleep(1);
44 }
45 pthread_exit((void *)0);
46 }
47
48 void *thrd_func2(void *arg){
49 printf("Thread 2 is running!\n");
50 sleep(5);
51 if (pthread_cancel(tid1)==0) // 线程tid2向线程tid1发送cancel
52 printf("Send Cancel cmd to Thread 1.\n");
53
54 pthread_exit((void *)0);
55 }

 

 1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <pthread.h>
4
5 #define THREAD_NUM 3
6 #define REPEAT_TIMES 5
7 #define DELAY 4
8
9 void *thrd_func(void *arg);
10
11 int main(){
12 pthread_t thread[THREAD_NUM];
13 int no;
14 void *tret;
15
16 srand((int)time(0)); // 初始化随机函数发生器
17
18 for(no=0;no<THREAD_NUM;no++){
19 if (pthread_create(&thread[no],NULL,thrd_func,(void*)no)!=0) { // 创建THREAD_NUM个线程,传入(void*)no作为thrd_func的参数
20 printf("Create thread %d error!\n",no);
21 exit(1);
22 } else
23 printf("Create thread %d success!\n",no);
24 }
25
26 for(no=0;no<THREAD_NUM;no++){
27 if (pthread_join(thread[no],&tret)!=0){ // 等待thread[no]线程结束,线程函数返回值放在tret中
28 printf("Join thread %d error!\n",no);
29 exit(1);
30 }else
31 printf("Join thread %d success!\n",no);
32 }
33
34 return 0;
35 }
36
37 void *thrd_func(void *arg){
38 int thrd_num=(void*)arg;
39 int delay_time=0;
40 int count=0;
41
42 printf("Thread %d is starting.\n",thrd_num);
43 for(count=0;count<REPEAT_TIMES;count++) {
44 delay_time=(int)(DELAY*(rand()/(double)RAND_MAX))+1;
45 sleep(delay_time);
46 printf("\tThread %d:job %d delay =%d.\n",thrd_num,count,delay_time);
47 }
48
49 printf("Thread %d is exiting.\n",thrd_num);
50 pthread_exit(NULL);
51 }



 

线程同步与互斥
  线程共享进程的资源和地址空间,对这些资源进行操作时,必须考虑线程间同步与互斥问题
  三种线程同步机制
    •互斥锁
    •信号量
    •条件变量
  互斥锁更适合同时可用的资源是惟一的情况
    信号量更适合同时可用的资源为多个的情况 

 

 

互斥锁
  用简单的加锁方法控制对共享资源的原子操作
  只有两种状态: 上锁、解锁
可把互斥锁看作某种意义上的全局变量
  在同一时刻只能有一个线程掌握某个互斥锁,拥有上锁状态的线程能够对共享资源进行操作
  若其他线程希望上锁一个已经被上锁的互斥锁,则该线程就会挂起,直到上锁的线程释放掉互斥锁为止
互斥锁保证让每个线程对共享资源按顺序进行原子操作

 

 

互斥锁分类
  区别在于其他未占有互斥锁的线程在希望得到互斥锁时是否需要阻塞等待
  快速互斥锁
    •调用线程会阻塞直至拥有互斥锁的线程解锁为止
    •默认为快速互斥锁
  检错互斥锁
    •为快速互斥锁的非阻塞版本,它会立即返回并返回一个错误信息
 
互斥锁主要包括下面的基本函数:
  互斥锁初始化:pthread_mutex_init()
  互斥锁上锁:pthread_mutex_lock()
  互斥锁判断上锁:pthread_mutex_trylock()
  互斥锁解锁:pthread_mutex_unlock()
  消除互斥锁:pthread_mutex_destroy()
 

 1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <pthread.h> 4  5 #define THREAD_NUM 3 6 #define REPEAT_TIMES 5 7 #define DELAY 4 8  9 pthread_mutex_t mutex;10 11 void *thrd_func(void *arg);12 13 int main(){14     pthread_t thread[THREAD_NUM];15     int no;16     void *tret;17     18     srand((int)time(0));19        // 创建快速互斥锁(默认),锁的编号返回给mutex    20     pthread_mutex_init(&mutex,NULL);21 22     // 创建THREAD_NUM个线程,每个线程号返回给&thread[no],每个线程的入口函数均为thrd_func,参数为23     for(no=0;no<THREAD_NUM;no++){24         if (pthread_create(&thread[no],NULL,thrd_func,(void*)no)!=0) {25             printf("Create thread %d error!\n",no);26             exit(1);27         } else28             printf("Create thread %d success!\n",no);29     }30     31     // 对每个线程进行join,返回值给tret32     for(no=0;no<THREAD_NUM;no++){33         if (pthread_join(thread[no],&tret)!=0){34             printf("Join thread %d error!\n",no);35             exit(1);36         }else37             printf("Join thread %d success!\n",no);38     }39     // 消除互斥锁40     pthread_mutex_destroy(&mutex);41     return 0;42 }43 44 void *thrd_func(void *arg){45     int thrd_num=(void*)arg; // 传入的参数,互斥锁的编号46     int delay_time,count; 47     48     // 对互斥锁上锁49     if(pthread_mutex_lock(&mutex)!=0) {50         printf("Thread %d lock failed!\n",thrd_num);51         pthread_exit(NULL);52     }53 54     printf("Thread %d is starting.\n",thrd_num);55     for(count=0;count<REPEAT_TIMES;count++) {56         delay_time=(int)(DELAY*(rand()/(double)RAND_MAX))+1;57         sleep(delay_time);58         printf("\tThread %d:job %d delay =%d.\n",thrd_num,count,delay_time);59     }60 61     printf("Thread %d is exiting.\n",thrd_num);62     // 解锁63     pthread_mutex_unlock(&mutex);64     65     pthread_exit(NULL);66 }

和上一版本的程序差异在于有没有锁,有锁的情况下,必须等"thread x is exiting."之后其他线程才能继续。

 

信号量
  操作系统中所用到的PV原子操作,广泛用于进程或线程间的同步与互斥
    •本质上是一个非负的整数计数器,被用来控制对公共资源的访问
  PV原子操作:对整数计数器信号量sem的操作
    •一次P操作使sem减一,而一次V操作使sem加一
    •进程(或线程)根据信号量的值来判断是否对公共资源具有访问权限
  –当信号量sem的值大于等于零时,该进程(或线程)具有公共资源的访问权限
  –当信号量sem的值小于零时,该进程(或线程)就将阻塞直到信号量sem的值大于等于0为止
 
PV操作主要用于线程间的同步和互斥
  互斥,几个线程只设置一个信号量sem
  同步,会设置多个信号量,安排不同初值来实现它们之间的顺序执行

 

信号量函数
  sem_init() 创建一个信号量,并初始化它
  sem_wait()和sem_trywait(): P操作,在信号量大于零时将信号量的值减一
    •区别: 若信号量小于零时,sem_wait()将会阻塞线程,sem_trywait()则会立即返回
  sem_post(): V操作,将信号量的值加一同时发出信号来唤醒等待的线程
  sem_getvalue(): 得到信号量的值
  sem_destroy(): 删除信号量

 

eg. 同步各线程,执行顺序为逆序。

 1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <pthread.h> 4 #include <semaphore.h> 5  6 #define THREAD_NUM 3 7 #define REPEAT_TIMES 5 8 #define DELAY 4 9 10 sem_t sem[THREAD_NUM];11 12 void *thrd_func(void *arg);13 14 int main(){15     pthread_t thread[THREAD_NUM];16     int no;17     void *tret;18     19     srand((int)time(0)); 20 21     // 初始化THREAD_NUM-1个信号量,均初始化为022     for(no=0;no<THREAD_NUM-1;no++){23         sem_init(&sem[no],0,0);24     }25 26     // sem[2]信号量初始化为1,即sem数组中最后一个信号量27     sem_init(&sem[2],0,1);28     29     // 创建THREAD_NUM个线程,入口函数均为thrd_func,参数为(void*)no30     for(no=0;no<THREAD_NUM;no++){31         if (pthread_create(&thread[no],NULL,thrd_func,(void*)no)!=0) {32             printf("Create thread %d error!\n",no);33             exit(1);34         } else35             printf("Create thread %d success!\n",no);36     }37     38     // 逐个join掉THREAD_NUM个线程39     for(no=0;no<THREAD_NUM;no++){40         if (pthread_join(thread[no],&tret)!=0){41             printf("Join thread %d error!\n",no);42             exit(1);43         }else44             printf("Join thread %d success!\n",no);45     }46     47     // 逐个取消信号量48     for(no=0;no<THREAD_NUM;no++){49         sem_destroy(&sem[no]);50     }51 52     return 0;53 }54 55 void *thrd_func(void *arg){56     int thrd_num=(void*)arg; // 参数no57     int delay_time,count;58 59     // 带有阻塞的p操作60     sem_wait(&sem[thrd_num]);61 62     63     printf("Thread %d is starting.\n",thrd_num);64     for(count=0;count<REPEAT_TIMES;count++) {65         delay_time=(int)(DELAY*(rand()/(double)RAND_MAX))+1;66         sleep(delay_time);67         printf("\tThread %d:job %d delay =%d.\n",thrd_num,count,delay_time);68     }69 70     printf("Thread %d is exiting.\n",thrd_num);71     72     // 对前一个信号量进行V操作73     // 由于只有最后一个信号量初始化为1,其余均为074     // 故线程执行的顺序将为逆序75     sem_post(&sem[(thrd_num+THREAD_NUM-1)%THREAD_NUM]);76 77     pthread_exit(NULL); // 线程主动结束78 }

 

多线程的创建与等待

所有线程都有一个线程号,也就是Thread ID。其类型为pthread_t。通过调用pthread_self()函数可以获得自身的线程号。
下面说一下如何创建一个线程。
通过创建线程,线程将会执行一个线程函数,该线程格式必须按照下面来声明:
void * Thread_Function(void *)
创建线程的函数如下:

 

1 int pthread_create(pthread_t *restrict thread,2 const pthread_attr_t *restrict attr,3 void *(*start_routine)(void*), void *restrict arg);

下面说明一下各个参数的含义:
thread:所创建的线程号。
attr:所创建的线程属性,这个将在后面详细说明。
start_routine:即将运行的线程函数。
art:传递给线程函数的参数。
下面是一个简单的创建线程例子:

 1 #include <pthread.h> 2 #include <stdio.h> 3 /* Prints x’s to stderr. The parameter is unused. Does not return. */ 4 void* print_xs (void* unused) 5 { 6     while (1) 7     fputc (‘x’, stderr); 8     return NULL; 9 }10 /* The main program. */11 int main ()12 {13     pthread_t thread_id;14     /* Create a new thread. The new thread will run the print_xs15     function. */16     pthread_create (&thread_id, NULL, &print_xs, NULL);17     /* Print o’s continuously to stderr. */18     while (1)19     fputc (‘o’, stderr);20     return 0;21 }

 

在编译的时候需要注意,由于线程创建函数在libpthread.so库中,所以在编译命令中需要将该库导入。命令如下:
gcc –o createthread –lpthread createthread.c
如果想传递参数给线程函数,可以通过其参数arg,其类型是void *。如果你需要传递多个参数的话,可以考虑将这些参数组成一个结构体来传递。另外,由于类型是void *,所以你的参数不可以被提前释放掉。
下面一个问题和前面创建线程类似,不过带来的问题回避进程要严重得多。如果你的主线程,也就是main函数执行的那个线程,在你其他线程推出之前就已经退出,那么带来的bug则不可估量。通过pthread_join函数会让主线程阻塞,直到所有线程都已经退出。

pthread_join:使一个线程等待另一个线程结束。
代码中如果没有pthread_join主线程会很快结束从而使整个进程结束,从而使创建的线程没有机会开始执行就结束了。加入pthread_join后,主线程会一直等待直到等待的线程结束自己才结束,使创建的线程有机会执行。

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

thread:等待退出线程的线程号。
value_ptr:退出线程的返回值。
下面一个例子结合上面的内容:

 1 int main () 2 { 3     pthread_t thread1_id; 4     pthread_t thread2_id; 5     struct char_print_parms thread1_args; 6     struct char_print_parms thread2_args; 7     /* Create a new thread to print 30,000 x’s. */ 8     thread1_args.character = ’x’; 9     thread1_args.count = 30000;10     pthread_create (&thread1_id, NULL, &char_print, &thread1_args);11     /* Create a new thread to print 20,000 o’s. */12     thread2_args.character = ’o’;13     thread2_args.count = 20000;14     pthread_create (&thread2_id, NULL, &char_print, &thread2_args);15     /* Make sure the first thread has finished. */16     pthread_join (thread1_id, NULL);17     /* Make sure the second thread has finished. */18     pthread_join (thread2_id, NULL);19     /* Now we can safely return. */20     return 0;21 }

 

下面说一下前面提到的线程属性。
在我们前面提到,可以通过pthread_join()函数来使主线程阻塞等待其他线程退 出,这样主线程可以清理其他线程的环境。但是还有一些线程,更喜欢自己来清理退出的状态,他们也不愿意主线程调用pthread_join来等待他们。我 们将这一类线程的属性称为detached。如果我们在调用pthread_create()函数的时候将属性设置为NULL,则表明我们希望所创建的线 程采用默认的属性,也就是jionable。如果需要将属性设置为detached,则参考下面的例子:

 1 #include <stdio.h> 2 #include <pthread.h> 3 void * start_run(void * arg) 4 { 5 //do some work 6 } 7 int main() 8 { 9     pthread_t thread_id;10     pthread_attr_t attr;11     pthread_attr_init(&attr);12     pthread_attr_setdetachstate(&attr,PTHREAD_CREATE_DETACHED);13     pthread_create(&thread_id,&attr,start_run,NULL);14     pthread_attr_destroy(&attr);15     sleep(5);16     exit(0);17 }

 

在线程设置为joinable后,可以调用pthread_detach()使之成为detached。但是相反的操作则不可以。还有,如果线程已经调用pthread_join()后,则再调用pthread_detach()则不会有任何效果。
线程可以通过自身执行结束来结束,也可以通过调用pthread_exit()来结束线程的执行。另外,线程甲可以被线程乙被动结束。这个通过调用pthread_cancel()来达到目的。

int pthread_cancel(pthread_t thread);

函数调用成功返回0。
当然,线程也不是被动的被别人结束。它可以通过设置自身的属性来决定如何结束。
线程的被动结束分为两种,一种是异步终结,另外一种是同步终结。异步终结就是当其他线程调用 pthread_cancel的时候,线程就立刻被结束。而同步终结则不会立刻终结,它会继续运行,直到到达下一个结束点(cancellation point)。当一个线程被按照默认的创建方式创建,那么它的属性是同步终结。
通过调用pthread_setcanceltype()来设置终结状态。

int pthread_setcanceltype(int type, int *oldtype);

state:要设置的状态,可以为PTHREAD_CANCEL_DEFERRED或者为PTHREAD_CANCEL_ASYNCHRONOUS。
那么前面提到的结束点又是如何设置了?最常用的创建终结点就是调用pthread_testcancel()的地方。该函数除了检查同步终结时的状态,其他什么也不做。
上面一个函数是用来设置终结状态的。还可以通过下面的函数来设置终结类型,即该线程可不可以被终结:

int pthread_setcancelstate(int state, int *oldstate);

state:终结状态,可以为PTHREAD_CANCEL_DISABLE或者PTHREAD_CANCEL_ENABLE。具体什么含义大家可以通过单词意思即可明白。
最后说一下线程的本质。其实在Linux中,新建的线程并不是在原先的进程中,而是系统通过 一个系统调用clone()。该系统copy了一个和原先进程完全一样的进程,并在这个进程中执行线程函数。不过这个copy过程和fork不一样。 copy后的进程和原先的进程共享了所有的变量,运行环境。这样,原先进程中的变量变动在copy后的进程中便能体现出来。

 

Linux 线程实现机制分析

自从多线程编程的概念出现在 Linux 中以来,Linux 多线应用的发展总是与两个问题脱不开干系:兼容性、效率。本文从线程模型入手,通过分析目前 Linux 平台上最流行的 LinuxThreads 线程库的实现及其不足,描述了 Linux 社区是如何看待和解决兼容性和效率这两个问题的。

一.基础知识:线程和进程

按照教科书上的定义,进程是资源管理的最小单位,线程是程序执行的最小单位。在操作系统设计上,从进程演化出线程,最主要的目的就是更好的支持SMP以及减小(进程/线程)上下文切换开销。

无论按照怎样的分法,一个进程至少需要一个线程作为它的指令执行体,进程管理着资源(比如cpu、内存、文件等等),而将线程分配到某个cpu上执行。一个进程当然可以拥有多个线程,此时,如果进程运行在SMP机器上,它就可以同时使用多个cpu来执行各个线程,达到最大程度的并行,以提高效率;同时,即使是在单cpu的机器上,采用多线程模型来设计程序,正如当年采用多进程模型代替单进程模型一样,使设计更简洁、功能更完备,程序的执行效率也更高,例如采用多个线程响应多个输入,而此时多线程模型所实现的功能实际上也可以用多进程模型来实现,而与后者相比,线程的上下文切换开销就比进程要小多了,从语义上来说,同时响应多个输入这样的功能,实际上就是共享了除cpu以外的所有资源的。

针对线程模型的两大意义,分别开发出了核心级线程和用户级线程两种线程模型,分类的标准主要是线程的调度者在核内还是在核外。前者更利于并发使用多处理器的资源,而后者则更多考虑的是上下文切换开销。在目前的商用系统中,通常都将两者结合起来使用,既提供核心线程以满足smp系统的需要,也支持用线程库的方式在用户态实现另一套线程机制,此时一个核心线程同时成为多个用户态线程的调度者。正如很多技术一样,"混合"通常都能带来更高的效率,但同时也带来更大的实现难度,出于"简单"的设计思路,Linux从一开始就没有实现混合模型的计划,但它在实现上采用了另一种思路的"混合"。

在线程机制的具体实现上,可以在操作系统内核上实现线程,也可以在核外实现,后者显然要求核内至少实现了进程,而前者则一般要求在核内同时也支持进程。核心级线程模型显然要求前者的支持,而用户级线程模型则不一定基于后者实现。这种差异,正如前所述,是两种分类方式的标准不同带来的。

当核内既支持进程也支持线程时,就可以实现线程-进程的"多对多"模型,即一个进程的某个线程由核内调度,而同时它也可以作为用户级线程池的调度者,选择合适的用户级线程在其空间中运行。这就是前面提到的"混合"线程模型,既可满足多处理机系统的需要,也可以最大限度的减小调度开销。绝大多数商业操作系统(如Digital Unix、Solaris、Irix)都采用的这种能够完全实现POSIX1003.1c标准的线程模型。在核外实现的线程又可以分为"一对一"、"多对一"两种模型,前者用一个核心进程(也许是轻量进程)对应一个线程,将线程调度等同于进程调度,交给核心完成,而后者则完全在核外实现多线程,调度也在用户态完成。后者就是前面提到的单纯的用户级线程模型的实现方式,显然,这种核外的线程调度器实际上只需要完成线程运行栈的切换,调度开销非常小,但同时因为核心信号(无论是同步的还是异步的)都是以进程为单位的,因而无法定位到线程,所以这种实现方式不能用于多处理器系统,而这个需求正变得越来越大,因此,在现实中,纯用户级线程的实现,除算法研究目的以外,几乎已经消失了。

Linux内核只提供了轻量进程的支持,限制了更高效的线程模型的实现,但Linux着重优化了进程的调度开销,一定程度上也弥补了这一缺陷。目前最流行的线程机制LinuxThreads所采用的就是线程-进程"一对一"模型,调度交给核心,而在用户级实现一个包括信号处理在内的线程管理机制。Linux-LinuxThreads的运行机制正是本文的描述重点。

 

二.Linux 2.4内核中的轻量进程实现

最初的进程定义都包含程序、资源及其执行三部分,其中程序通常指代码,资源在操作系统层面上通常包括内存资源、IO资源、信号处理等部分,而程序的执行通常理解为执行上下文,包括对cpu的占用,后来发展为线程。在线程概念出现以前,为了减小进程切换的开销,操作系统设计者逐渐修正进程的概念,逐渐允许将进程所占有的资源从其主体剥离出来,允许某些进程共享一部分资源,例如文件、信号,数据内存,甚至代码,这就发展出轻量进程的概念。Linux内核在2.0.x版本就已经实现了轻量进程,应用程序可以通过一个统一的clone()系统调用接口,用不同的参数指定创建轻量进程还是普通进程。在内核中,clone()调用经过参数传递和解释后会调用do_fork(),这个核内函数同时也是fork()、vfork()系统调用的最终实现:

1 <linux-2.4.20/kernel/fork.c>2 int do_fork(unsigned long clone_flags, unsigned long stack_start, 3 struct pt_regs *regs, unsigned long stack_size)

 

其中的clone_flags取自以下宏的"或"值:

 1 <linux-2.4.20/include/linux/sched.h> 2 #define CSIGNAL      0x000000ff   3 /* signal mask to be sent at exit */ 4 #define CLONE_VM    0x00000100  5 /* set if VM shared between processes */ 6 #define CLONE_FS        0x00000200   7 /* set if fs info shared between processes */ 8 #define CLONE_FILES     0x00000400   9 /* set if open files shared between processes */10 #define CLONE_SIGHAND  0x00000800 11 /* set if signal handlers and blocked signals shared */12 #define CLONE_PID    0x00001000  13 /* set if pid shared */14 #define CLONE_PTRACE  0x00002000  15 /* set if we want to let tracing continue on the child too */16 #define CLONE_VFORK  0x00004000  17 /* set if the parent wants the child to wake it up on mm_release */18 #define CLONE_PARENT  0x00008000  19 /* set if we want to have the same parent as the cloner */20 #define CLONE_THREAD  0x00010000  21 /* Same thread group? */22 #define CLONE_NEWNS  0x00020000  /* New namespace group? */23 #define CLONE_SIGNAL   (CLONE_SIGHAND | CLONE_THREAD)

 

在do_fork()中,不同的clone_flags将导致不同的行为,对于LinuxThreads,它使用(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND)参数来调用clone()创建"线程",表示共享内存、共享文件系统访问计数、共享文件描述符表,以及共享信号处理方式。本节就针对这几个参数,看看Linux内核是如何实现这些资源的共享的。

1.CLONE_VM

do_fork()需要调用copy_mm()来设置task_struct中的mm和active_mm项,这两个mm_struct数据与进程所关联的内存空间相对应。如果do_fork()时指定了CLONE_VM开关,copy_mm()将把新的task_struct中的mm和active_mm设置成与current的相同,同时提高该mm_struct的使用者数目(mm_struct::mm_users)。也就是说,轻量级进程与父进程共享内存地址空间,由下图示意可以看出mm_struct在进程中的地位:

2.CLONE_FS

task_struct中利用fs(struct fs_struct *)记录了进程所在文件系统的根目录和当前目录信息,do_fork()时调用copy_fs()复制了这个结构;而对于轻量级进程则仅增加fs->count计数,与父进程共享相同的fs_struct。也就是说,轻量级进程没有独立的文件系统相关的信息,进程中任何一个线程改变当前目录、根目录等信息都将直接影响到其他线程。

3.CLONE_FILES

一个进程可能打开了一些文件,在进程结构task_struct中利用files(struct files_struct *)来保存进程打开的文件结构(struct file)信息,do_fork()中调用了copy_files()来处理这个进程属性;轻量级进程与父进程是共享该结构的,copy_files()时仅增加files->count计数。这一共享使得任何线程都能访问进程所维护的打开文件,对它们的操作会直接反映到进程中的其他线程。

4.CLONE_SIGHAND

每一个Linux进程都可以自行定义对信号的处理方式,在task_struct中的sig(struct signal_struct)中使用一个struct k_sigaction结构的数组来保存这个配置信息,do_fork()中的copy_sighand()负责复制该信息;轻量级进程不进行复制,而仅仅增加signal_struct::count计数,与父进程共享该结构。也就是说,子进程与父进程的信号处理方式完全相同,而且可以相互更改。

do_fork()中所做的工作很多,在此不详细描述。对于SMP系统,所有的进程fork出来后,都被分配到与父进程相同的cpu上,一直到该进程被调度时才会进行cpu选择。

尽管Linux支持轻量级进程,但并不能说它就支持核心级线程,因为Linux的"线程"和"进程"实际上处于一个调度层次,共享一个进程标识符空间,这种限制使得不可能在Linux上实现完全意义上的POSIX线程机制,因此众多的Linux线程库实现尝试都只能尽可能实现POSIX的绝大部分语义,并在功能上尽可能逼近。

 

三.LinuxThread的线程机制

LinuxThreads是目前Linux平台上使用最为广泛的线程库,由Xavier Leroy (Xavier.Leroy@inria.fr)负责开发完成,并已绑定在GLIBC中发行。它所实现的就是基于核心轻量级进程的"一对一"线程模型,一个线程实体对应一个核心轻量级进程,而线程之间的管理在核外函数库中实现。

1.线程描述数据结构及实现限制

LinuxThreads定义了一个struct _pthread_descr_struct数据结构来描述线程,并使用全局数组变量__pthread_handles来描述和引用进程所辖线程。在__pthread_handles中的前两项,LinuxThreads定义了两个全局的系统线程:__pthread_initial_thread和__pthread_manager_thread,并用__pthread_main_thread表征__pthread_manager_thread的父线程(初始为__pthread_initial_thread)。

struct _pthread_descr_struct是一个双环链表结构,__pthread_manager_thread所在的链表仅包括它一个元素,实际上,__pthread_manager_thread是一个特殊线程,LinuxThreads仅使用了其中的errno、p_pid、p_priority等三个域。而__pthread_main_thread所在的链则将进程中所有用户线程串在了一起。经过一系列pthread_create()之后形成的__pthread_handles数组将如下图所示:

图2 __pthread_handles数组结构

新创建的线程将首先在__pthread_handles数组中占据一项,然后通过数据结构中的链指针连入以__pthread_main_thread为首指针的链表中。这个链表的使用在介绍线程的创建和释放的时候将提到。

LinuxThreads遵循POSIX1003.1c标准,其中对线程库的实现进行了一些范围限制,比如进程最大线程数,线程私有数据区大小等等。在LinuxThreads的实现中,基本遵循这些限制,但也进行了一定的改动,改动的趋势是放松或者说扩大这些限制,使编程更加方便。这些限定宏主要集中在sysdeps/unix/sysv/linux/bits/local_lim.h(不同平台使用的文件位置不同)中,包括如下几个:

每进程的私有数据key数,POSIX定义_POSIX_THREAD_KEYS_MAX为128,LinuxThreads使用PTHREAD_KEYS_MAX,1024;私有数据释放时允许执行的操作数,LinuxThreads与POSIX一致,定义PTHREAD_DESTRUCTOR_ITERATIONS为4;每进程的线程数,POSIX定义为64,LinuxThreads增大到1024(PTHREAD_THREADS_MAX);线程运行栈最小空间大小,POSIX未指定,LinuxThreads使用PTHREAD_STACK_MIN,16384(字节)。

2.管理线程

"一对一"模型的好处之一是线程的调度由核心完成了,而其他诸如线程取消、线程间的同步等工作,都是在核外线程库中完成的。在LinuxThreads中,专门为每一个进程构造了一个管理线程,负责处理线程相关的管理工作。当进程第一次调用pthread_create()创建一个线程的时候就会创建(__clone())并启动管理线程。

在一个进程空间内,管理线程与其他线程之间通过一对"管理管道(manager_pipe[2])"来通讯,该管道在创建管理线程之前创建,在成功启动了管理线程之后,管理管道的读端和写端分别赋给两个全局变量__pthread_manager_reader和__pthread_manager_request,之后,每个用户线程都通过__pthread_manager_request向管理线程发请求,但管理线程本身并没有直接使用__pthread_manager_reader,管道的读端(manager_pipe[0])是作为__clone()的参数之一传给管理线程的,管理线程的工作主要就是监听管道读端,并对从中取出的请求作出反应。

创建管理线程的流程如下所示: 
(全局变量pthread_manager_request初值为-1)

图3 创建管理线程的流程

初始化结束后,在__pthread_manager_thread中记录了轻量级进程号以及核外分配和管理的线程id,2*PTHREAD_THREADS_MAX+1这个数值不会与任何常规用户线程id冲突。管理线程作为pthread_create()的调用者线程的子线程运行,而pthread_create()所创建的那个用户线程则是由管理线程来调用clone()创建,因此实际上是管理线程的子线程。(此处子线程的概念应该当作子进程来理解。)

__pthread_manager()就是管理线程的主循环所在,在进行一系列初始化工作后,进入while(1)循环。在循环中,线程以2秒为timeout查询(__poll())管理管道的读端。在处理请求前,检查其父线程(也就是创建manager的主线程)是否已退出,如果已退出就退出整个进程。如果有退出的子线程需要清理,则调用pthread_reap_children()清理。

然后才是读取管道中的请求,根据请求类型执行相应操作(switch-case)。具体的请求处理,源码中比较清楚,这里就不赘述了。

3.线程栈

在LinuxThreads中,管理线程的栈和用户线程的栈是分离的,管理线程在进程堆中通过malloc()分配一个THREAD_MANAGER_STACK_SIZE字节的区域作为自己的运行栈。

用户线程的栈分配办法随着体系结构的不同而不同,主要根据两个宏定义来区分,一个是NEED_SEPARATE_REGISTER_STACK,这个属性仅在IA64平台上使用;另一个是FLOATING_STACK宏,在i386等少数平台上使用,此时用户线程栈由系统决定具体位置并提供保护。与此同时,用户还可以通过线程属性结构来指定使用用户自定义的栈。因篇幅所限,这里只能分析i386平台所使用的两种栈组织方式:FLOATING_STACK方式和用户自定义方式。

在FLOATING_STACK方式下,LinuxThreads利用mmap()从内核空间中分配8MB空间(i386系统缺省的最大栈空间大小,如果有运行限制(rlimit),则按照运行限制设置),使用mprotect()设置其中第一页为非访问区。该8M空间的功能分配如下图:

图4 栈结构示意

低地址被保护的页面用来监测栈溢出。

对于用户指定的栈,在按照指针对界后,设置线程栈顶,并计算出栈底,不做保护,正确性由用户自己保证。

不论哪种组织方式,线程描述结构总是位于栈顶紧邻堆栈的位置。

4.线程id和进程id

每个LinuxThreads线程都同时具有线程id和进程id,其中进程id就是内核所维护的进程号,而线程id则由LinuxThreads分配和维护。

__pthread_initial_thread的线程id为PTHREAD_THREADS_MAX,__pthread_manager_thread的是2*PTHREAD_THREADS_MAX+1,第一个用户线程的线程id为PTHREAD_THREADS_MAX+2,此后第n个用户线程的线程id遵循以下公式:

	tid=n*PTHREAD_THREADS_MAX+n+1

这种分配方式保证了进程中所有的线程(包括已经退出)都不会有相同的线程id,而线程id的类型pthread_t定义为无符号长整型(unsigned long int),也保证了有理由的运行时间内线程id不会重复。

从线程id查找线程数据结构是在pthread_handle()函数中完成的,实际上只是将线程号按PTHREAD_THREADS_MAX取模,得到的就是该线程在__pthread_handles中的索引。

5.线程的创建

在pthread_create()向管理线程发送REQ_CREATE请求之后,管理线程即调用pthread_handle_create()创建新线程。分配栈、设置thread属性后,以pthread_start_thread()为函数入口调用__clone()创建并启动新线程。pthread_start_thread()读取自身的进程id号存入线程描述结构中,并根据其中记录的调度方法配置调度。一切准备就绪后,再调用真正的线程执行函数,并在此函数返回后调用pthread_exit()清理现场。

6.LinuxThreads的不足

由于Linux内核的限制以及实现难度等等原因,LinuxThreads并不是完全POSIX兼容的,在它的发行README中有说明。

1)进程id问题

这个不足是最关键的不足,引起的原因牵涉到LinuxThreads的"一对一"模型。

Linux内核并不支持真正意义上的线程,LinuxThreads是用与普通进程具有同样内核调度视图的轻量级进程来实现线程支持的。这些轻量级进程拥有独立的进程id,在进程调度、信号处理、IO等方面享有与普通进程一样的能力。在源码阅读者看来,就是Linux内核的clone()没有实现对CLONE_PID参数的支持。

在内核do_fork()中对CLONE_PID的处理是这样的:

          if (clone_flags & CLONE_PID) {                if (current->pid)                        goto fork_out;        }

这段代码表明,目前的Linux内核仅在pid为0的时候认可CLONE_PID参数,实际上,仅在SMP初始化,手工创建进程的时候才会使用CLONE_PID参数。

按照POSIX定义,同一进程的所有线程应该共享一个进程id和父进程id,这在目前的"一对一"模型下是无法实现的。

2)信号处理问题

由于异步信号是内核以进程为单位分发的,而LinuxThreads的每个线程对内核来说都是一个进程,且没有实现"线程组",因此,某些语义不符合POSIX标准,比如没有实现向进程中所有线程发送信号,README对此作了说明。

如果核心不提供实时信号,LinuxThreads将使用SIGUSR1和SIGUSR2作为内部使用的restart和cancel信号,这样应用程序就不能使用这两个原本为用户保留的信号了。在Linux kernel 2.1.60以后的版本都支持扩展的实时信号(从_SIGRTMIN到_SIGRTMAX),因此不存在这个问题。

某些信号的缺省动作难以在现行体系上实现,比如SIGSTOP和SIGCONT,LinuxThreads只能将一个线程挂起,而无法挂起整个进程。

3)线程总数问题

LinuxThreads将每个进程的线程最大数目定义为1024,但实际上这个数值还受到整个系统的总进程数限制,这又是由于线程其实是核心进程。

在kernel 2.4.x中,采用一套全新的总进程数计算方法,使得总进程数基本上仅受限于物理内存的大小,计算公式在kernel/fork.c的fork_init()函数中:

	max_threads = mempages / (THREAD_SIZE/PAGE_SIZE) / 8

在i386上,THREAD_SIZE=2*PAGE_SIZE,PAGE_SIZE=2^12(4KB),mempages=物理内存大小/PAGE_SIZE,对于256M的内存的机器,mempages=256*2^20/2^12=256*2^8,此时最大线程数为4096。

但为了保证每个用户(除了root)的进程总数不至于占用一半以上物理内存,fork_init()中继续指定:

    init_task.rlim[RLIMIT_NPROC].rlim_cur = max_threads/2;    init_task.rlim[RLIMIT_NPROC].rlim_max = max_threads/2;

这些进程数目的检查都在do_fork()中进行,因此,对于LinuxThreads来说,线程总数同时受这三个因素的限制。

4)管理线程问题

管理线程容易成为瓶颈,这是这种结构的通病;同时,管理线程又负责用户线程的清理工作,因此,尽管管理线程已经屏蔽了大部分的信号,但一旦管理线程死亡,用户线程就不得不手工清理了,而且用户线程并不知道管理线程的状态,之后的线程创建等请求将无人处理。

5)同步问题

LinuxThreads中的线程同步很大程度上是建立在信号基础上的,这种通过内核复杂的信号处理机制的同步方式,效率一直是个问题。

6)其他POSIX兼容性问题

Linux中很多系统调用,按照语义都是与进程相关的,比如nice、setuid、setrlimit等,在目前的LinuxThreads中,这些调用都仅仅影响调用者线程。

7)实时性问题

线程的引入有一定的实时性考虑,但LinuxThreads暂时不支持,比如调度选项,目前还没有实现。不仅LinuxThreads如此,标准的Linux在实时性上考虑都很少。

 

四.其他的线程实现机制

LinuxThreads的问题,特别是兼容性上的问题,严重阻碍了Linux上的跨平台应用(如Apache)采用多线程设计,从而使得Linux上的线程应用一直保持在比较低的水平。在Linux社区中,已经有很多人在为改进线程性能而努力,其中既包括用户级线程库,也包括核心级和用户级配合改进的线程库。目前最为人看好的有两个项目,一个是RedHat公司牵头研发的NPTL(Native Posix Thread Library),另一个则是IBM投资开发的NGPT(Next Generation Posix Threading),二者都是围绕完全兼容POSIX 1003.1c,同时在核内和核外做工作以而实现多对多线程模型。这两种模型都在一定程度上弥补了LinuxThreads的缺点,且都是重起炉灶全新设计的。

1.NPTL

NPTL的设计目标归纳可归纳为以下几点:

  • POSIX兼容性
  • SMP结构的利用
  • 低启动开销
  • 低链接开销(即不使用线程的程序不应当受线程库的影响)
  • 与LinuxThreads应用的二进制兼容性
  • 软硬件的可扩展能力
  • 多体系结构支持
  • NUMA支持
  • 与C++集成

在技术实现上,NPTL仍然采用1:1的线程模型,并配合glibc和最新的Linux Kernel2.5.x开发版在信号处理、线程同步、存储管理等多方面进行了优化。和LinuxThreads不同,NPTL没有使用管理线程,核心线程的管理直接放在核内进行,这也带了性能的优化。

主要是因为核心的问题,NPTL仍然不是100%POSIX兼容的,但就性能而言相对LinuxThreads已经有很大程度上的改进了。

2.NGPT

IBM的开放源码项目NGPT在2003年1月10日推出了稳定的2.2.0版,但相关的文档工作还差很多。就目前所知,NGPT是基于GNU Pth(GNU Portable Threads)项目而实现的M:N模型,而GNU Pth是一个经典的用户级线程库实现。

按照2003年3月NGPT官方网站上的通知,NGPT考虑到NPTL日益广泛地为人所接受,为避免不同的线程库版本引起的混乱,今后将不再进行进一步开发,而今进行支持性的维护工作。也就是说,NGPT已经放弃与NPTL竞争下一代Linux POSIX线程库标准。

3.其他高效线程机制

此处不能不提到Scheduler Activations。这个1991年在ACM上发表的多线程内核结构影响了很多多线程内核的设计,其中包括Mach3.0、NetBSD和商业版本Digital Unix(现在叫Compaq True64 Unix)。它的实质是在使用用户级线程调度的同时,尽可能地减少用户级对核心的系统调用请求,而后者往往是运行开销的重要来源。采用这种结构的线程机制,实际上是结合了用户级线程的灵活高效和核心级线程的实用性,因此,包括Linux、FreeBSD在内的多个开放源码操作系统设计社区都在进行相关研究,力图在本系统中实现Scheduler Activations。

参考资料

  • [Linus Torvalds,2002] Linux内核源码v2.4.20
  • [GNU,2002] Glibc源码v2.2.2(内含LinuxThreads v0.9)
  • [Thomas E. Terrill,1997] An Introduction to Threads Using The LinuxThreads Interface
  • [Ulrich Drepper,Ingo Molnar,2003] The Native POSIX Thread Library for Linux
  • http://www.ibm.com/developerworks/oss/pthreads/,NGPT官方网站
  • [Ralf S. Engelschall,2000] Portable Multithreading
  • [Thomas E. Anderson, Brian N. Bershad, Edward D. Lazowska, Henry M. Levy,1992] Scheduler Activations: Effective Kernel Support for the User-Level Management of Parallelism
  • [pcjockey@21cn.com] Linux线程初探