Linux从0到1——初识线程
- 1. 什么是线程?
- 1.1 线程的概念
- 1.2 结合代码理解线程
- 1.3 重谈地址空间
- 1.4 线程的优缺点
- 2. 线程异常和线程用途
- 3. Linux进程VS线程
- 4. 详解pthread_create的arg参数
- 4.1 给线程传参
- 4.2 一次创建多个线程
- 5. 线程控制
- 5.1 线程退出
- 5.2 线程分离
- 5.3 线程取消
- 6. 详解线程id
1. 什么是线程?
1.1 线程的概念
1. 线程概念
- 课本上的概念:
- 线程是比进程更加轻量化的一种执行流;
- 线程是在进程内部执行的一种执行流。
- 我们的理解:
- 线程是CPU调度的基本单位;
- 进程是承担系统资源的基本实体。
2. 理解线程
- 每创建一个进程,就需要创建对应的地址空间
mm_struct
,PCB,页表。开销实际上是比较大的。那么可不可以设计一种更轻量级的进程?让这种轻量级进程,和主进程共用一个地址空间,共用一个页表? - 上面的方案从技术上来说一定是可行的,我们只需要另外为这个轻量级进程创建一个PCB,然后将主进程的地址空间划分出一块给这个新进程即可。我们将这种轻量级进程,叫做“线程”。
- “线程”只参与资源的分配,而不参与资源的创建。
3. 线程的管理(Linux)
- OS中如果要支持线程,也就必须要对它进行管理,即先描述,再组织。那是不是意味着,需要再设计一个TCB即线程控制块,来描述它?以及再设计一套线程的调度方法?这样是不是有点太复杂了。
- 实际上,PCB中已经包含了一个执行流需要的所有信息,而且基于PCB的调度算法也是十分完备的。所以线程只需要复用PCB,和PCB的调度方法即可。
4. Linux中线程的实现方案
- 很多操作系统的书籍中,只是告诉你一个操作系统中应该有什么,而不会告诉你具体怎么实现。所以,操作系统更像是一种指导文档,告诉我们一个线程应该有什么样的特点,而不会告诉我们具体怎么实现。
- 我们上面谈的线程管理,只是Linux中对线程的管理方案。线程复用PCB。
- 从CPU的角度来看,如何区分这个PCB是线程还是进程?答案是不需要区分,CPU的任务只是调度执行流,不关心是进程还是线程。就好比一个快递员的主要任务是发送快递,而不关心快递包装中具体装的是什么。
- Linux中不存在严格意义上的线程,CPU在调度时,将每一个PCB都看作是一个“轻量级进程(Light Weight Process)”,线程只是对用户层的一个封装。CPU中的基本调度单位是轻量级进程
LWP
。
5. 如何看待之前学习的进程,和今天所学的进程?
- 之前所学的进程,是内部只有一个执行流的进程;
- 今天学习的是内核中有多个执行流的进程。
6. 从CPU调度的角度,理解线程的轻量化
- 之前我们只是提到了,线程的创建更加轻量化,只需要创建对应的PCB即可。
- 就CPU调度而言,切换进程,需要切换CPU中大量的寄存器信息,包括页表的,地址空间的等等。但是对于线程,只需要切换一些临时寄存器中的信息即可。
- 上面提到的仍然不是线程切换解决的主要矛盾,线程切换真正的优势和CPU中的一个硬件
cache
有关:- 根据局部性原理,访问某一行代码时,下一次有较大概率访问这段代码前后一部分的代码。比如现在程序运行到了
main
函数的第10行,那么极有可能下一次运行的就是0到20行中的其中一段代码。科学家们根据这一原理设计出了cache
,它的作用是将一段代码的前后多行代码加载进CPU,下一次访问时先在cache
中查找有没有要执行的代码,如果没有再去内存中找,提高了效率。我们将cache
中的数据叫作热数据。 - 进程在切换时,需要重新对
cache
中的数据做热加载,即刷新cache
。而线程切换则不需要刷新cache
。这就是线程最主要的优势。
- 根据局部性原理,访问某一行代码时,下一次有较大概率访问这段代码前后一部分的代码。比如现在程序运行到了
7. 进程时间片也要被内部的线程瓜分,时间片也是资源
- 如果OS会为每一个线程也分配时间片,这就是一个严重的
bug
。用户可以通过不断创建线程,来抢占CPU资源,获得优先调度权。
1.2 结合代码理解线程
1. pthread_create函数
- 可以看到该接口在3号手册中,所以这不是一个系统接口,而是库函数。
- 参数:
pthread_t *thread
:输出型参数,返回线程标识符;const pthread_attr_t *attr
:指向线程属性对象,定义了线程的属性,如果不需要特殊属性可以传入NULL
。void *(*start_routine) (void *)
:线程启动时执行的函数,它必须返回一个void*
类型的值,并且接受一个void*
类型的参数。void *arg
:传递给start_routine
函数的参数。
这里了解一下即可,之后还会细讲。
2. 实验代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>void *ThreadRoutine(void *args)
{usleep(1000); // 让主线程先打印const char *threadname = (const char *)args;while(true){std::cout << "I am a new thread:" << threadname << ", pid:" << getpid() << std::endl;sleep(1); }
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, ThreadRoutine, (void*)"thread 1"); // 创建线程// 主线程while(true){std::cout << "I am main thread" << ", pid: " << getpid() << std::endl;sleep(1);}return 0;
}
- 由于
<pthread.h>
线程库是第三方库,所以编译时要带上选项-lpthread
。
3. 实验现象
ps -aL
命令会列出所有终端的进程,并且对于每个多线程的进程,它会显示该进程下所有线程的详细信息。
- 上图中显示的
LWP
这一列对应的数据是轻量级进程id
,可以看到,两个线程的PID
相同,属于同一进程,并且主线程的PID
和LWP
相同。
1.3 重谈地址空间
1. 页帧和页框
- 文件系统IO的基本单位大小为4KB,这个大小又叫Page size。因为有这个规则的存在,所以从逻辑上将文件
hello.exe
划分成一块一块的4kb空间,每一块叫做一个页帧。 - 将磁盘文件换入到内存时,也是按4kb的大小换入的,为了适配页帧,物理内存也划分出了一个一个4kb大小的页框。
- 有了页框,OS还需要知道每个页框的使用情况,有没有被占用,是否异常等各种信息。先描述,再组织。
struct page
{// 描述一个页框的使用情况// page的属性int flag;...
}
- 有了描述页框的结构体,还需要将他们管理起来,使用了数组
struct page pages[1048576]
,所以,对内存的管理,就变成了对数组内容的增删查改。
2. 重谈页表
- 假设现在是32位机器,那么虚拟地址就有232个。如果按照我们之前理解的页表,一个虚拟地址对应一个物理地址,假设页表中一行有8byte(当前不止8byte,只是假设),那么一个页表的大小就有4GBX8byte,这远远超出了内存的承受范围。所以之前的理解是片面的,是不正确不严谨的。
- 真实的页表结构如上图所示。32位机器的虚拟地址有32位,前10位是一个数组下标,通过该下标可以在页目录中找到下一级的页表。中间10位也是数组下标,通过该下标可以找到物理内存中的页框起始地址。最后12位则是偏移量,通过这样一套页框起始地址+偏移量的方式,就可以精准定位到物理内存中的每一个字节了。
- 为什么偏移量有12位?因为一个页框的大小是4KB,正好就是212字节。这样一来,页表最大也就只有220x4byte,远远小于之前的232x8byte。
- 并且页表是动态开辟的,一个页表用完了,就再开辟一个,这就又缩小了页表的大小。
3. 划分进程的本质
- 划分进程资源的本质,就是划分页表。划分页表的本质,是划分地址空间。
1.4 线程的优缺点
1. 线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多;
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
线程占用的资源要比进程少很多; - 能充分利用多处理器的可并行数量;
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务;
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现;
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
2. 线程的缺点
- 性能损失
- 一个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低
- 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制
- 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高
- 编写与调试一个多线程程序比单线程程序困难得多。
2. 线程异常和线程用途
1. 线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃;
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
2. 线程用途
- 合理的使用多线程,能提高CPU密集型程序的执行效率;
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)。
3. Linux进程VS线程
1. 进程VS线程
- 进程是资源分配的基本单位;
- 线程是调度的基本单位。
2. 线程共享进程数据,但也拥有自己的一部分数据
- 线程ID;
- 一组寄存器,独立的硬件上下文数据;
- 独立的栈结构;
errno
;- 信号屏蔽字;
- 调度优先级。
3. 进程的多个线程共享同一地址空间,因此数据段、代码段都是共享的。如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表;
- 每种信号的处理方式(
SIG_ IGN
、SIG_ DFL
或者自定义的信号处理函数); - 当前工作目录;
- 用户
id
和组id
。
4. 进程和线程关系图
4. 详解pthread_create的arg参数
4.1 给线程传参
通过该函数的arg
参数给线程传参。arg
是void*
类型的参数,这就说明我们可以传任意类型的参数。以下是一个传参的示例,使用了一个自定义类型描述线程的相关信息。
#include <iostream>
#include <string>
#include <functional>
#include <time.h>
#include <unistd.h>
#include <pthread.h>// typedef std::function<void()> func_t; 下面的写法和这句代码等价
using func_t = std::function<void()>; // C++11新语法// 线程的相关数据
class ThreadData
{
public:ThreadData(const std::string &name, const uint64_t &ctime, func_t f):threadname(name), createtime(ctime), func(f){}public:std::string threadname; // 线程名uint64_t createtime; // 线程创建时间func_t func; // 该线程执行的方法
};void Print()
{std::cout << "我是线程执行的大任务的一部分" << std::endl;
}// 新线程
void *ThreadRountine(void *args)
{ThreadData *td = static_cast<ThreadData*>(args);while(true){std::cout << "new thread" << std::endl;std::cout << "thread name: " << td->threadname << std::endl;std::cout << "create time: " << td->createtime << std::endl;td->func();sleep(3);}
}// 主线程
int main()
{pthread_t tid;ThreadData *td = new ThreadData("thread 1", (uint64_t)(time(nullptr)), Print);// 传参直接传tdpthread_create(&tid, nullptr, ThreadRountine, td);while(true){std::cout << "main thread" << std::endl;sleep(1);}return 0;
}
4.2 一次创建多个线程
#include <iostream>
#include <string>
#include <functional>
#include <vector>
#include <time.h>
#include <unistd.h>
#include <pthread.h>// typedef std::function<void()> func_t; 下面的写法和这句代码等价
using func_t = std::function<void()>;#define threadnum 5class ThreadData
{
public:ThreadData(const std::string &name, const uint64_t &ctime, func_t f):threadname(name), createtime(ctime), func(f){}public:std::string threadname;uint64_t createtime;func_t func;
};// 新线程
void *ThreadRountine(void *args)
{ThreadData *td = static_cast<ThreadData*>(args);while(true){std::cout << "new thread." << " thread name: " << td->threadname << " create time: " << td->createtime << std::endl;sleep(1);}
}int main()
{std::vector<pthread_t> pthreads; // 存储线程的tidfor (size_t i = 0; i < threadnum; i++){char threadname[64];snprintf(threadname, sizeof(threadname), "%s-%lu", "thread", i);pthread_t tid;ThreadData *td = new ThreadData(threadname, (uint64_t)(time(nullptr)), nullptr);pthread_create(&tid, nullptr, ThreadRountine, td);pthreads.push_back(tid);sleep(1); // 隔一秒创建一个线程}while(true){sleep(1);}return 0;
}
5. 线程控制
5.1 线程退出
1. 线程退出的两种方式
- 直接
return
; - 使用
pthread_exit()
函数;- 该函数的
retval
参数,就是线程想返回给主进程的内容。
- 该函数的
2. 主线程如何获得新线程的退出信息?
pthread_join()
函数可以等待线程退出,类似于进程等待。线程退出,没有等待,会导致类似进程的僵尸问题。- 其中
thread
参数指的是要等待的线程id
,retval
参数则是用来接收新线程的返回值。
- 返回值:等待成功返回0;不成功返回错误码
errno
。
3. 以下是一个完成的线程等待+获取退出信息示例
#include <iostream>
#include <string>
#include <functional>
#include <time.h>
#include <unistd.h>
#include <pthread.h>class ThreadReturn
{
public:ThreadReturn(pthread_t id, const std::string &info, int code):_id(id), _info(info), _code(code){}
public:pthread_t _id; // 线程idstd::string _info; // 退出信息int _code; // 退出码
};void *threadRoutine(void *arg)
{std::string name = static_cast<const char*>(arg);int cnt = 5;while(cnt--){std::cout << "new thread is running, thread name: " << name << " tid: " << pthread_self() << std::endl; // pthread_self函数返回线程自己的idsleep(1);}ThreadReturn *ret = new ThreadReturn(pthread_self(), "thread quit normal", 10);// 以下两种线程退出方式任选其一// 1. return ret; // 2. pthread_exit(ret);// 这里选第一种return ret;
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, threadRoutine, (void*)"thread-1");// 线程默认要被等待!// 1. 线程退出,没有等待,会导致类似进程的僵尸问题// 2. 线程退出时,主线程如何获取新线程的返回值?void *ret = nullptr;int n = pthread_join(tid, &ret);ThreadReturn *r = static_cast<ThreadReturn*>(ret);std::cout << "main thread get new thread return: " << r->_id << ", " << r->_info<< ", " << r->_code<< std::endl;std::cout << "main thread done. n: " << n << std::endl;return 0;
}
5.2 线程分离
1. 线程分离的使用场景
- 在多线程场景中,如果主线程
pthread_join
了某一个新线程,且该新线程未退出,则主线程会发生阻塞等待。如果我们不希望主线程阻塞等待该新线程,可以将该新线程设置为分离状态。分离状态下的新线程,退出后会被自动回收,不需要主线程进行等待。 - 当主线程不关心新线程任务的完成情况时,就可以使用线程分离。
2. 设置线程分离的两种方式
- 直接在新线程的任务块中,调用
pthread_detach
方法:
// 新线程任务块
void *threadRoutine(void *arg)
{// 线程分离pthread_detach(pthread_self());...
}// 主线程
int main()
{...
}
- 在主线程中,调用
pthread_detach
:
// 新线程
void *threadRoutine(void *arg)
{...
}// 主线程
int main()
{pthread_t tid;pthread_create(&tid, nullptr, threadRoutine, (void*)"thread-1");// 线程分离pthread_detach(tid);...
}
如果新线程被设置分离了,主线程还
pthread_join
它,错误码被设置,返回22。
5.3 线程取消
1. pthread_cancel函数
- 功能是取消一个执行中的线程。
- 参数
thread
是线程id
。 - 执行成功返回0,失败返回错误码。
2. 代码示例
#include <iostream>
#include <string>
#include <functional>
#include <time.h>
#include <unistd.h>
#include <pthread.h>void *threadRoutine(void *arg)
{std::string name = static_cast<const char*>(arg);while(1){std::cout << "new thread is running, thread name: " << name << " tid: " << pthread_self() << std::endl; // pthread_self函数返回线程自己的idsleep(1);}return nullptr;
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, threadRoutine, (void*)"thread-1");// 3秒后取消新线程sleep(3);pthread_cancel(tid);// 等待新线程void *ret = nullptr;int n = pthread_join(tid, &ret);std::cout << "main thread done. n: " << n << " ret: " << (int64_t)ret << std::endl;return 0;
}
- 这里新线程被主线程取消了,虽然新线程没有执行自己的
return
退出语句,但是主线程也拿到了ret
返回值,是-1。被取消的线程的返回值,默认都是-1。
分离状态的线程也可以被取消,分离和取消不冲突,但是和等待
pthread_join
冲突。
6. 详解线程id
1. 问题引入
#include <iostream>
#include <string>
#include <functional>
#include <time.h>
#include <unistd.h>
#include <pthread.h>void *threadRoutine(void *arg)
{std::string name = static_cast<const char*>(arg);while(1){std::cout << "new thread is running, thread name: " << name << " tid: " << pthread_self() << std::endl; // pthread_self函数返回线程自己的idsleep(1);}return nullptr;
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, threadRoutine, (void*)"thread-1");while(true){std::cout << "main thread, sub thread: " << tid << std::endl;sleep(1);}return 0;
}
- 为什么
LWP
,即轻量级进程id
,和线程的tid
不同?线程id
到底是什么?
2. pthread共享库
- Linux没有真正的线程,只有轻量级进程的概念,所以Linux OS只会提供轻量级进程创建的系统调用,不会直接提供线程创建的接口。
- 但是我们学习操作系统时,一直接触的就是线程,难道为了学Linux,还要再花功夫专门学一下轻量级进程吗?这显示是不合理的,所以Linux的解决方案是,在用户层和系统层之间,加入了一层软件层,封装了轻量级进程的调用接口,对上直接提供线程的控制接口。
- 这层中间的软件层,就是
pthread
原生线程库。
Linux为什么不直接在系统层设计线程,还非要搞一个轻量级进程,最后还必须用一个库来封装成线程?
- 首先,直接在系统层设计线程,技术上是绝对可以实现的。Linux之所以没有采取这种方式,是因为它觉得轻量级进程,是自己系统的一大特色,而不是缺点。其中一个特色就是,轻量级进程复用PCB,调度方法也复用进程的调度方法。
- 一个完整的Linux系统,默认是携带
pthread
原生线程库的,如果没有,那么这个Linux系统是残缺的。
3. 线程管理
- 我们之前所使用的线程控制接口,全部都不是系统接口,而是原生线程库
pthread
提供的接口。 - 既然可以同时存在多个线程,那么肯定也需要对线程进行管理(先描述,再组织),在Linux中,这个管理工作是交给
pthread
库来执行的(先提出这个概念,一步步慢慢理解)。 - 线程要有自己独立的硬件上下文和独立的栈结构,默认地址空间中的栈,由主线程使用。
- 事实上,Linux系统也提供了直接创建轻量级进程的接口:
child_stack
参数允许用户显示的传栈地址,也就是指定分配的栈地址空间。
pthread_create
底层封装的就是这个接口,也会自动帮我们分配该轻量级进程的栈地址空间。pthread
库中会有专门的结构化数据struct tcb
,将轻量级进程封装为线程,来存储每个LWP的相关信息,包括该LWP所占用的栈空间地址。tcb
需要和LWP一一对应,所以也要存储该LWP对应的轻量级进程id
。pthread
作为共享库,一次加载后,被所有进程所共享,所以各个进程可以看到多个用户启动的所有线程。
4. tid
- 每创建一个线程,
pthread
中就会记录一个线程属性集合,包括struct pthread
,线程局部存储,线程栈。这个线程属性集合,就可以理解成是tcb
。 - 所谓
tid
,线程id
,实际上就是各个属性集合的地址,所以tid
的本质是一个地址。 - 这个属性集合中,还存有类似
void *ret
的数据,用来保存线程的返回值。这个返回值可以通过pthread_join
方法获取。所以线程的返回值并不是存储在系统中的,而是存储在库中的。
5. 站在语言角度,理解pthread
- C++11的多线程,本质上,就是对原生线程库的封装。
#include <iostream>
#include <thread>// 这是一个简单的函数,将作为线程执行的任务
void print_numbers(int n) {for (int i = 0; i < n; ++i) {std::cout << "Number: " << i << std::endl;}
}int main() {std::cout << "Main thread starting." << std::endl;// 创建一个线程,传递函数print_numbers和它的参数std::thread t(print_numbers, 10); // 这里的10是print_numbers函数的参数// 在主线程中做一些事情std::cout << "Main thread doing other work." << std::endl;// 等待线程t完成t.join();std::cout << "Main thread finished." << std::endl;return 0;
}
- 上面这部分代码,在Linux上跑,就会去调用Linux的原生线程库。在Windows上跑,就会调用Windows的原生线程库。同样的代码可以跑在不同的操作系统上,这就是语言层面的可移植性。
6. 线程的局部存储
- 全局变量本身是被所有线程所共享的,这不难理解。全局变量存储在数据段,而所有线程共享一个地址空间。
- 给全局变量前面加上
__thread
修饰(这实际上是一个编译选项),每个线程就会在自己的tcb
中,多开辟一块空间,存储这个变量。这样每个线程实际上就拥有了自己独立的全局变量。这就是线程的局部存储。