目录
一,Linux线程概念
1,什么是线程
-
在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是"⼀个进程内部的控制序列"。
-
一切进程至少都有一个执行线程。
-
线程在进程内部运行,本质是在进程地址空间内运行。
-
在Linux系统中,在 CPU 眼中,看到的 PCB 都要比传统的进程更加轻量化。
-
透过进程虚拟 地址空间 ,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。

所以,Linux内核中没有线程的概念,只有进程的概念,使用轻量级进程模拟线程。
2,分页式存储管理
虚拟地址和页表的由来
如果在没有虚拟内存和分页机制的情况下,每一个用户程序在物理内存上所对应的空间必须是连续的,如下图:

因为每一个程序的代码、数据长度都是不一样的,按照这样的映射方式,物理内存将会被分割成各种离散的、大小不同的块。经过一段运行时间之后,有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎片的形式存在。
我们希望操作系统提供给用户的空间必须是连续的,但是物理内存最好不要连续。此时虚拟内存和分页便出现了,如下图所示:

把物理内存按照⼀个固定的长度的页框进行分割,有时叫做物理页。每个页框包含一个物理页(page)。⼀个页的大小等于页框的大小。大多数 32位体系结构支持 4KB 的页,而64位体系结构一般会支持 8KB 的页。区分一页和一个页框是很重要的:
-
页框是一个存储区域;
-
而页是一个数据块,可以存放在任何页框或磁盘中。
有了这种机制,CPU 便并非是直接访问物理内存地址,而是通过虚拟地址空间来间接的访问物理内存地址。所谓的虚拟地址空间,是操作系统为每一个正在执行的进程分配的一个逻辑地址,在32位机上,其范围从0 ~ 4G-1。
操作系统通过将虚拟地址空间和物理内存地址之间建立映射关系,也就是页表,这张表上记录了每一对页和页框的映射关系,能让CPU间接的访问物理内存地址。
总结一下,其思想是将虚拟内存下的逻辑地址空间分为若干页,将物理内存空间分为若干页框,通过页表便能把连续的虚拟内存,映射到若干个不连续的物理内存页。这样就解决了使用连续的物理内存造成的碎片问题。
物理内存管理
假设一个可用的物理内存有 4GB 的空间。按照一个页框的大小4KB 进行划分, 4GB 的空间就是4GB/4KB = 1048576 个页框。有这么多的物理页,操作系统肯定是要将其管理起来的,操作系统需要知道哪些页正在被使用 ,哪些页空闲等等。
内核用struct page 结构表示系统中的每个物理页,出于节省内存的考虑, struct page 中使用了大量的联合体union。
页表
页表中的每⼀个表项,指向⼀个物理页的开始地址。在 32 位系统中,虚拟内存的最大空间是4GB ,这是每一个用户程序都拥有的虚拟内存空间。既然需要让 4GB 的虚拟内存全部可用,那么页表中就需要能够表示这所有的 4GB 空间,那么就一共需要 4GB/4KB = 1048576 个表项。如下图所示:

页表中的物理地址,与物理内存之间,是随机的映射关系,哪里可用指向哪里(物理页)。虽然最终使用的物理内存是离散的,但是与虚拟内存对应的线性地址是连续的。处理器在访问数据、获取指令时,使用的都是线性地址,只要它是连续的就可以了,最终都能够通过页表找到实际的物理地址。
假设,在 32 位系统中,地址的长度是 4 个字节,那么页表中的每一个表项就是占用 4 个字节。所以页表占据的总空间大小就是:1048576*4 = 4MB 的大小。也就是说映射表自己本身,就要占用4MB / 4KB = 1024 个物理页。这会存在哪些问题呢?
解决需要大容量页表的最好方法是:把页表看成普通的文件,对它进行离散分配,即对页表再分页,由此形成多级页表的思想。
为了解决这个问题,可以把这个单一页表拆分成 1024 个体积更小的映射表。如下图所示,这样一来,1024(每个表中的表项个数) * 1024(表的个数),仍然可以覆盖 4GB 的物理内存空间。

这里的每一个表,就是真正的页表,所以一共有 1024 个页表。⼀个页表自身占用 4KB ,那么1024 个页表一共就占⽤了 4MB 的物理内存空间,和之前没差别啊?
从总数上看是这样,但是一个应用程序是不可能完全使用全部的 4GB 空间的,也许只要几十个页表就可以了。例如:一个用户程序的代码段、数据段、栈段,一共就需要 10 MB 的空间,那么使用 3 个页表就足够了。
计算过程:
每一个页表项指向一个4KB 的物理页,那么一个页表中1024 个页表项,一共能覆盖 4MB 的物理内存;
那么 10MB 的程序,向上对齐取整之后(4MB 的倍数,就是 12 MB),就需要 3 个页表就可以了。
3,线程的优缺点
线程的优点:
1,创建一个新线程的代价要比创建一个新进程小得多。
2,与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。
-
最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。
-
另外⼀个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别是当你改变虚拟内存空间的时候,处理的页表缓冲 TLB (快表)会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题,当然还有硬件cache。
3,线程占用的资源要比进程少。
4,能充分利用多处理器的可并行数量。
5,在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。
线程的缺点:
1,性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同一个处理器。这里的性能损失指
的是增加了额外的同步和调度开销,而可用的资源不变。
2,健壮性降低
编写多线程需要更全面更深入的考虑,在夜歌多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
3,缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
4,编程难度提高
编写与调试一个多线程程序比单线程程序困难得多。
线程异常:
1,单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
2,线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
二,Linux线程控制
1,POSIX线程库
与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以"pthread_"打头的
要使用这些函数库,要通过引入头文件 <pthread.h>
链接这些线程函数库时要使用编译器命令的"-lpthread"选项
2,创建线程
功能:创建⼀个新的线程
原型:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *
(*start_routine)(void*), void *arg);
参数:
thread:返回线程ID
attr:设置线程的属性,attr为NULL表示使用默认属性
start_routine:是个函数地址,线程启动后要执行的函数
arg:传给线程启动函数的参数
返回值:成功返回0;失败返回错误码
// 获取线程ID
pthread_t pthread_self(void);
错误检查:
-
传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
-
pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做),而是将错误代码通过返回值返回。
-
pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值判定,因为读取返回值要比读取线程内的errno变量的开销更小。
示例:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <pthread.h>
void *rout(void *arg) {
int i;
for( ; ; )
{
printf("I'am thread 1\n");
sleep(1);
}
}
int main()
{
pthread_t tid;
int ret;
if ( (ret=pthread_create(&tid, NULL, rout, NULL)) != 0 )
{
fprintf(stderr, "pthread_create : %s\n", strerror(ret));
exit(EXIT_FAILURE);
}
int i;
for(; ; )
{
printf("I'am main thread\n");
sleep(1);
}
}
3,终止线程
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
-
从线程函数return。这种方法对主线程不适用,从main函数return相当于调⽤exit。
-
线程可以调用 pthread_ exit终止自己。
-
一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
pthread_exit函数
功能:线程终⽌
原型:
void pthread_exit(void *value_ptr);
参数:
value_ptr:value_ptr不要指向⼀个局部变量。
返回值:
无返回值,跟进程⼀样,线程结束的时候无法返回到它的调用者(自身)
需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
pthread_cancel函数
功能:取消⼀个执⾏中的线程
原型:
int pthread_cancel(pthread_t thread);
参数:
thread:线程ID
返回值:成功返回0;失败返回错误码
4,线程等待
为什么需要线程等待?
-
已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
-
创建新的线程不会复用刚才退出线程的地址空间。
功能:等待线程结束
原型
int pthread_join(pthread_t thread, void **value_ptr);
参数:
thread:线程ID
value_ptr:它指向⼀个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码
调用该函数的线程将挂起等待,直到id为thread的线程终⽌。thread线程以不同的⽅法终⽌,通过
pthread_join得到的终止状态是不同的,总结如下:
-
如果thread线程通过return返回,value_ ptr所指向的单元⾥里存放的是thread线程函数的返回值。
-
如果thread线程被别的线程调⽤pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED。
-
如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
-
如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。
样例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
void *thread1( void *arg )
{
printf("thread 1 returning ... \n");
int *p = (int*)malloc(sizeof(int));
*p = 1;
return (void*)p;
}
void *thread2( void *arg )
{
printf("thread 2 exiting ...\n");
int *p = (int*)malloc(sizeof(int));
*p = 2;
pthread_exit((void*)p);
}
void *thread3( void *arg )
{
while (1)
{ //
printf("thread 3 is running ...\n");
sleep(1);
}
return NULL;
}
int main( void )
{
pthread_t tid;
void *ret;
// thread 1 return
pthread_create(&tid, NULL, thread1, NULL);
pthread_join(tid, &ret);
printf("thread return, thread id %X, return code:%d\n", tid, *(int*)ret);
free(ret);
// thread 2 exit
pthread_create(&tid, NULL, thread2, NULL);
pthread_join(tid, &ret);
printf("thread return, thread id %X, return code:%d\n", tid, *(int*)ret);
free(ret);
// thread 3 cancel by other
pthread_create(&tid, NULL, thread3, NULL);
sleep(3);
pthread_cancel(tid);
pthread_join(tid, &ret);
if ( ret == PTHREAD_CANCELED )
printf("thread return, thread id %X, return code:PTHREAD_CANCELED\n",tid);
else
printf("thread return, thread id %X, return code:NULL\n", tid);
}
5,分离线程
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
如果不关心线程的返回值,join是⼀种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
int pthread_detach(pthread_t thread);
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
pthread_detach(pthread_self());
joinable和分离是冲突的,一个线程不能既是joinable又是分离的。
三,线程ID及进程地址空间布局
Linux系统下的线程概念是在pthread库中维护的,对于Linux系统是没有线程这个概念的,只有进程。也就是说,pthread库实际上是封装了Linux中对轻量级进程的操作,向用户展示出线程的概念。
我们要使用线程,就需要链接这个库,从而形成可执行程序。
线程的概念是在库中维护的,在库内部 ,就一定会存在多个被创建好的线程,这些线程在库内部使用一个结构体描述,再选择一种数据结构讲这些线程组织起来,至此,库就可以管理这些线程了。
比如,在pthread库内部,可以定义一个诸如struct tcb的结构体,其成员可以包括线程状态,线程id,线程独立栈结构,线程栈大小等等。而这个结构中不应该包含线程的上下文数据,线程的时间片,线程的调度优先级等等数据,因为这些概念都是系统级别的,这些信息是在系统中保存的。

虽然 Linux 将线程和进程不加区分的统一到了 task_struct ,但是对待其地址空间的 stack 还是有些区别的。
对于 Linux 进程或者说主线程,简单理解就是main函数的栈空间,在fork的时候,实际上就是复制了父进程的 stack 空间地址,然后写时拷贝(cow)以及动态增长。如果扩充超出该上限则栈溢出会报段错误(发送段错误信号给该进程)。进程栈是唯一可以访问未映射页而不一定会发生段错误⸺超出扩充上限才报。
然而对于主线程生成的子线程而言,其 stack 将不再是向下生成的,而是事先固定下来的。线程栈一般是调用glibc/uclibc等的 pthread 库接口pthread_create 创建的线程,在文件映射区(或称之为共享区),其中使用mmap 系统调用。
