目录

  • 1 :peach:线程概念 :peach:
    • 1.1 :apple:什么是线程?:apple:
    • 1.2 :apple:线程的优点和缺点:apple:
    • 1.3 :apple:页表的大小:apple:
    • 1.4 :apple:线程异常和用途:apple:
    • 1.5 :apple:进程VS线程:apple:
  • 2 :peach:线程控制:peach:
    • 2.1 :apple:POSIX线程库:apple:
    • 2.2 :apple:创建线程:apple:
    • 2.3 :apple:线程退出:apple:
      • :lemon:pthread_join:lemon:
      • :lemon:pthread_exit:lemon:
      • :lemon:pthread_cancel:lemon:
    • 2.4 :apple:分离线程:apple:
    • 2.5 :apple:理解线程独立栈:apple:

1 线程概念

1.1 什么是线程?

教材观点是这样的:线程是一个执行分支,执行力度比进程更细,调度的成本更低。
Linux内核观点:进程是系统分配资源的基本单位,线程是CPU调度的基本单位。

这两种说法都是正确的,但是我们究竟该如何理解线程呢?

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

Linux中其实本质上并不存在线程,而是将轻量级进程作为线程。其本质就是OS并没有给线程创建自己独立的地址空间,而是与进程共用一套地址空间,那么这也就注定了线程中绝大部分资源是可以共享的。这样设计的好处是复用了PCB的那一套设计,线程的TCB可以用进程的PCB模拟出来,这样的设计更加简单并且维护效率更加高效,像服务器等开发选择Linux的原因就是因为Linux可以在长时间的服务中运行。Windows中的线程才算的上是一种严格的线程,Windows的线程并没有复用进程的方法,而是创造了真正意义上的线程。

我们可以用一张图来表示进程与线程的关系:

1.2 线程的优点和缺点

线程的优点

  • 1️⃣创建一个新线程的代价要比创建一个新进程小得多。
  • 2️⃣与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。
  • 3️⃣线程占用的资源要比进程少很多。
  • 4️⃣能充分利用多处理器的可并行数量。
  • 5️⃣在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。
  • 6️⃣计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
  • 7️⃣I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

线程的缺点:

  • 1️⃣性能损失:
    一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
  • 2️⃣健壮性降低
    编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
  • 3️⃣缺乏访问控制
    进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
  • 4️⃣编程难度提高
    编写与调试一个多线程程序比单线程程序困难得多。

1.3 页表的大小

这里再补充一个小知识点:多线程中页表关系是怎样维护建立的呢?

我们知道,在X86的环境下,我们最多可以拥有232种虚拟地址,而将虚拟地址转化成物理地址的页表大小应该为多少呢?
如果按照每一个虚拟地址都建立一个对应映射的话,假设用一个四字节的整形变量int来维护,那么不算其他的,只算虚拟地址到物理地址的映射,那么至少得需要232 *8(大约32GB),那我们操作系统还玩不玩了,这样设计肯定是不合理的。

实际上,操作系统将每一个32比特位的虚拟地址做了如下划分:
这样进行页表大小计算时我们用的是前20个比特位,也就是220 ,然后通过下面方式进行页表映射:

那么最后12位到哪里去了呢?最后12位的虚拟地址是我们将虚拟地址转化位物理地址的偏移量,这个偏移量的大小恰好是212 (4KB),这个4KB是操作系统管理物理内存的单位,相信大家对于4KB一点儿也不陌生,因为我们讲解文件系统的时候磁盘与内存进行交互的单位也是以4KB位单位。这里为什么要使用4KB的大小进行交互而不是以字节进行交互呢?因为一个很著名的原理:局部性原理。通俗的来说,局部性原理就是预测未来CPU高速缓存的命中情况来提升效率。

所以通过这种方式我们只用了220 量级的大小空间来完成页表的建立,最多也就几MB而已,更何况并不是所有地址都会被用到,实际用到的地址可能只有几十字节大小。所以这种方式可以解决操作系统如何为页表分配合适的空间问题。
那么实际操作系统是如何分配资源给对象的呢?比如我们使用malloc一个资源是立马就会给你开空间的吗?显然不是这样的,操作系统为了高效是不会直接立马给你开空间的,而是产生一个缺页中断,当你真正使用该空间时才会去开空间。

1.4 线程异常和用途

线程异常:

  • 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
  • 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。

这里我们可以简单的验证一下,大家先可以先看看时如何创建线程的,后面我们会详细的讲解:
比如下面的我们让线程1出错:
Makefile:

mytest:Test.cppg++ -o $@ $^ -std=c++11 -lpthread.PHONY:cleanclean:rm -rf mytest

Test.cpp:

#include#include#includeusing namespace std;void *Run1(void *argv){int cnt = 4;while (true){cout << "I am t1,is running" << endl;sleep(1);if (--cnt == 0){char *str = "abcd";*str = 'Q';}}}void* Run2(void* argv){while(true){cout<<"I am t2,is running"<<endl;sleep(1);}}int main(){pthread_t t1,t2;pthread_create(&t1,nullptr,Run1,nullptr);pthread_create(&t2,nullptr,Run2,nullptr);while(true){ cout<<"I am main,is running"<<endl; sleep(1);}return 0;}

这里面值得注意的细节:创建线程时要引入头文件 ;链接时为了能够找到库,在Makefile种要指定库的名称-lpthread,要查找指定进程的所有线程可以使用下面命令:ps -aL | grep 进程名称;想要显示更加详细信息可以使用下面命令:ps -aL | head -1 && ps -aL | grep 进程名称 ;为了方便观察我们可以使用下面的命令脚本:while :;do ps -aL | head -1 && ps -aL | grep mytest;echo "************************************";sleep 1;done
我们来运行下观察下结果:
从图片中我们不难发现当其中一个线程崩溃而导致整个进程(所有线程)都挂掉了。

线程用途:

  • 合理的使用多线程,能提高CPU密集型程序的执行效率。
  • 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)

1.5 进程VS线程

  • 进程是资源分配的基本单位
  • 线程是调度的基本单位
  • 线程共享进程数据,但也拥有自己的一部分数据:
    1️⃣线程ID
    2️⃣一组寄存器
    3️⃣
    4️⃣errno
    5️⃣信号屏蔽字
    6️⃣调度优先级

进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
1️⃣文件描述符表
2️⃣每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
3️⃣当前工作目录
4️⃣用户id和组id

进程与线程的关系如下图:


2 线程控制

2.1 POSIX线程库

  • 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头;
  • 要使用这些函数库,要通过引入头文;
  • 链接这些线程函数库时要使用编译器命令的“-lpthread”选项.

2.2 创建线程

我们先来看看库中的基本介绍:

功能:创建一个新的线程
原型:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg)
参数:
thread:返回线程ID;
attr:设置线程的属性,attr为nullptr表示使用默认属性;
start_routine:是个函数地址,线程启动后要执行的函数;
arg:传给线程启动函数的参数;
返回值:成功返回0;失败返回错误码

这里再补充一个错误检查的知识点:

  • 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。pthread函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回。
  • pthread同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值来判定,因为读取返回值要比读取线程内的errno变量的开销更小。

我们观察上面创建线程的接口中,第一个参数是线程的id,这个的数值与之前我们在用监视脚本看的线程PID不太一样,现阶段可以理解为同一线程的两种不同的身份形式(比如我们在学校的学生证和处于社会中的身份证类似);第二个参数我们一般设置为空;第三个是一个参数为void*,返回值为void的函数指针;第四个参数是一个void的对象,我们一般是将线程的信息通过该参数传递进去的。

我们根据上面介绍就可以写出如下代码:

#include#includeusing namespace std;void* Run(void* args){const char* name=static_cast<char*> (args);while(true){cout<<name<<"is running"<<endl;sleep(1);}return nullptr;}int main(){pthread_t pids[5];for(int i=0;i<5;++i){char name[26];snprintf(name,sizeof(name),"pthread%d:",i+1);pthread_create(pids+i,nullptr,Run,name);}while(true){cout<<"I am is main thread,is running"<<endl;sleep(1);}return 0;}

当我们运行时:

为啥跟我们预计的不太一样呀?我们想要的是打印pthread1 pthread2……这样的数据呀,为啥打印出来的都是pthread5呢?
这其实与我们传入的数组有关:

我们在这里传入的是数组名,也就是首元素地址,我们传给线程创建的参数并不是一个缓冲区而是一个数组的地址,由于创建线程是先将线程创建出来,并不会立马去执行线程中的代码,而我们每次传入的地址(数组名)是相同的,所以最后一个线程的数据就被保存到了数组中,当我们并发执行线程中的代码时读到的就是数组中的数据(也就是最后一次修改数组中的数据)。那我们如何解决这种现象呢?
我们可以在堆上开辟空间,这样我们每次new出来的地址是不同的,所以就不会出现覆盖的情况了。

比如我们可以这样修改:

当我们再次运行时:

这里面打印顺序并不是1 2 3 4 5那样的原因是因为线程的调度也是不确定的,谁先调度完全是由调度器所决定的。

其实上面传入的对象大家可以更具需求设置的更加完善一些,我们可以封装一个类,让多线程帮助我们完成不同的任务,我这里就不再多写了,大家有兴趣可以根据自己的需求下去完善。

线程ID及进程地址空间布局:

  • pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。
  • 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
  • pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID.

2.3 线程退出

pthread_join

但是其实上面的代码中还存在一个很严重的问题,我们在学习进程中知道,父进程会wait子进程,否则就可能造成了内存泄漏。线程也是一样的,已经退出的线程,其空间没有被释放,仍然在进程的地址空间内;创建新的线程不会复用刚才退出线程的地址空间。主线程必须要回收其他线程的资源,否则就会造成内存泄漏,那回收其他线程的接口是啥呢?
我们来看看官网对pthread_join的介绍:

功能:等待线程结束
原型:
int pthread_join(pthread_t thread, void **value_ptr);
参数:
thread:线程ID
value_ptr:它指向一个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码

第一个参数比较好理解,那么第二个参数是一个二级指针,接受的是一个线程的返回值,那么我们知道创建线程的参数里面有一个函数指针,该函数指针的返回值为void*,而这个返回值就可以传递给join的第二个参数使用,比如我们看看下面的代码:

void *Run(void *args){const char *name = static_cast<char *>(args);cout << "thread1 is running" << endl;sleep(2);return (void *)11;}int main(){pthread_t p1;pthread_create(&p1, nullptr, Run, nullptr);cout << "I am is main thread,is running" << endl;sleep(2);void* ret=nullptr;pthread_join(p1,&ret);cout<<"new pthread exit "<<ret<<endl;return 0;}

当我们运行时:

我们发现我们通过返回值返回的11被join给接收到了。

pthread_exit

除了使用return 这种方式,我们还可以使用哪种方式终止线程呢?我们还可以使用pthread_exit接口来处理:

功能:线程终止
原型:
void pthread_exit(void *value_ptr);
参数:
value_ptr:value_ptr不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)

当我们这样使用时:

我们可以来观察下运行结果:

pthread_cancel

除了上面我们讲解的这两种方式外,我们还可以使用pthread_cancel取消一个执行中的线程:

功能:取消一个执行中的线程
原型:
int pthread_cancel(pthread_t thread);
参数:
thread:线程ID
返回值:成功返回0;失败返回错误码

假如我们想要取消自己呢?我们如何得到自己的pid,我们可以使用pthread_self():

我们下面来看看线程取消的基本用法:

void *threadRun(void* args){const char*name = static_cast<const char *>(args);int cnt = 5;while(cnt){cout << name << " is running: " << cnt-- << " obtain self id: " << pthread_self() << endl;sleep(1);}pthread_exit((void*)11); // PTHREAD_CANCELED; #define PTHREAD_CANCELED ((void *) -1)}int main(){pthread_t tid;pthread_create(&tid, nullptr, threadRun, (void*)"thread 1");sleep(3);pthread_cancel(tid);void *ret = nullptr;pthread_join(tid, &ret);cout << " new thread exit : " << (int64_t)ret << "quit thread: " << tid << endl;return 0;}

当我们直接运行时:

不难发现程序3s后直接退出了,其实也很好理解,因为我们退出的是主线程,所以肯定会直接退出的。

所以我们可以总结线程退出有三种方式:

  • 1️⃣从线程函数return,这种方法对主线程不适用,从main函数return相当于调用exit。
  • 2️⃣线程可以调用pthread_ exit终止自己。
  • 3️⃣一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。

调用pthread_join函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:

  • 1️⃣如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
  • 2️⃣ 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED(-1)。
  • 3️⃣ 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
  • 4️⃣ 如果对thread线程的终止状态不感兴趣,可以传nullptr给value_ ptr参数。

2.4 分离线程

  • 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
  • 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。

所以此时我们可以使用pthread_detach:

可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:

pthread_detach(pthread_self());

joinable和分离是冲突的,一个线程不能既是joinable又是分离的。

我们来验证下:

void* Run(void* args){pthread_detach(pthread_self());const char* name=static_cast<char*> (args);cout<<name<<" is running"<<endl;return nullptr;}int main(){pthread_t p1;pthread_create(&p1,nullptr,Run,(void*)"thread1"); int ret=pthread_join(p1,nullptr);if(ret==0)cout<<"wait success"<<endl;elsecout<<"wait fail "<<endl;return 0;}

当我们运行时:

为什么是运行success呀?不是说joinable和分离是冲突的吗?按道理这里应该会join失败的呀。
这是由于执行时是先执行的join,此时线程还没有被分离,自然就能够join成功了,我们可以像下面这样写,就会join失败:

当我们再次运行时:

2.5 理解线程独立栈

首先我们来看看一张图:
通过之前动静态库的知识我们知道,pthread库是加载到共享区的,那么也就决定了进程中所有线程都是可以访问得到该库的。但是从上图我们看见了有一个主线程栈的空间,这个空间又是为谁准备的呢?
其实这个空间是为主线程准备的,我们之前讲过其余线程中的栈是相互独立的,而这个独立栈的空间就开辟在共享区中,也就是独立栈的空间其实是由库帮助我们开辟的。上图右边第一个struct_pthread又是什么鬼呢?这个是管理共享区中线程的一种数据结构,类似于进程中的PCB。至于什么是局部存储,我们可以来写一个程序看看:

int g_val=20;void* Run(void* args){ const char* name=static_cast<char*> (args); while(true) {cout<<"g_val:"<<g_val<<"&g_val:"<<&g_val<<endl;sleep(1); }}int main(){pthread_t pids[5];for(int i=0;i<5;++i){char* name=new char[32];snprintf(name,32,"pthread%d:",i+1);pthread_create(pids+i,nullptr,Run,name);}for(int i=0;i<5;++i){pthread_join(pids[i],nullptr);}return 0;}

当我们运行时:

这也符合我们的预期,因为全局变量是所有线程共享的,但是当我们在全局变量前加上了__pthread后:
当我们运行时:
我们惊奇的发现居然地址不一样了,这其实就是将g_val分别保存了一份在各自的独立栈中。至于为什么打印出来的数据无规律是因为多线程并发访问的问题,我们后面在详细讲解。