从本章开始,我们进入Linux系统编程最后一节多线程的学习,本章我们先来简单的认识一下线程。
在我们之前的Linux学习中,学习了进程
的相关概念,操作系统内核中的task_struct
描述进程,CPU在运行时,会根据时间片轮询调度进程,让每个进程得以推进。
在之前进程地址空间
的学习中,我们知道,每个进程的PCB都可以看到一整个进程地址空间,我们以前学的进程是一个PCB对应一个进程地址空间。
而线程我们可以理解为轻量级进程
,每一个进程都可以创建多个线程,并行执行不同的代码。
线程 : 进程 = n : 1
创建的这三个PCB有了属于它们自己的一小份代码和数据。那么我们把这里的其中一个task_ struct
对应的占有这个的进程的一小份代码,一小份数据,使用它局部的一部分页表的,这样的执行流task_struct
在Linux中叫做线程
。
CPU看待进程和线程是一样的,调度的时候都是以task_struct为单位来调度的。
Windows中:
Linux中:
Linux的线程是用进程模拟的PCB模拟的,Linux下也有tcb只不过没有为线程单独设计,用的照样是task_struct
。
Linux没有提供纯纯的创建线程接口,因为底层没有用真线程,用的是进程作为载体去模拟线程。
进程具有独立性是,有自己的资源,地址空间,页表还有该进程加载到内存中的代码和数据。
以前创建进程是创建独立进程,PCB、地址空间和页表
是私有的。
创建线程只创建PCB,CPU调度时,只看PCB。
小结:
进程和线程在执行流层面是不一样的。
在Linux中,执行流(Execution Flow)是指程序的执行过程中的控制流动。它描述了程序中指令的顺序执行路径,决定了程序的执行顺序。
fork之后,父子是共享代码的可以通过
if else
判断,让父子进程执行不同的代码块不同的执行流,可以做到进行对特定资源的划分。
进程(Process)和线程(Thread)在执行流层面上是不一样的:
线程是进程内的一个执行单元,一个进程可以包含多个线程。
本来串行执行的代码,现在在CPU上可以并发或者并行
去执行,让代码在一个时间段或者一个时间点同时得以推进
,这种解决方案就叫做线程。
再看进程:
再说进程就是PCB就不准确了。包括地址空间,页表,包括构建的映射关系,包括在内存中申请的各种代码和数据对应的内存,包括对应的PCB合起来这一堆才叫进程。
进程的最大意义不是被执行而是:向系统申请资源的基本单位!
以前学的都是单执行流,执行流PCB本身也属于进程内部的资源。
线程是调度的基本单位。
进程切换的成本非常的高,但是进程和线程在CPU中看到的是一样的。
进程切换,地址空间,页表,包括曾经的数据基本都要切换。
内部的执行流就可以称之为一个线程,也就是说一个进程内部可以有一个或者多个线程,CPU调度时, 看到的基本单位全部都叫做线程
。
Linux中没有原生创建线程的接口,但是Linux有原生线程库,由应用级程序员帮我们开发出了一批接口, 叫做pthread_create
。
不是操作系统的接口,叫做原生线程库:
pthread_attr_t
类型的变量来设置自定义的属性。注意:
创建线程的时候,本质就是让线程执行进程代码的一部分,有一个进程里面有十几个函数,把某一个函数当做该线程的入口函数,让该线程去调度。
task_struct
都是一个进程。task_struct
都是一个执行流(线程)线程是属于某一个进程的,所以不需要创建新的mm_struct
和页表
映射,但是创建的效率高于创建子进程。创建新线程后(创建新的PCB)只要将task_struct
指向所属进程的mm_struct
即可。
在进程中,我们谈父子线程,在线程中,我们谈主新线程。
线程退出的时候,一般必须要进行join
,如果不进行join
:
返回值:
pthread_join第二个参数的理解:
pthread_join
第二个参数为什么是二级指针:
主线程为何没有获取新线程退出时的信号?
线程出异常了,不再是线程的问题了,而是进程的问题了。所以pthread_join不需要退出信号。
所以以后考虑线程终止,只考虑正常终止。
我们来创建两个线程,来分别查看一下进程和线程:
#include
#include
#include
// #include
#include // C++11的线程库
using namespace std;
void* callback1(void* args)
{
string name = (char*)args;
while (true)
{
cout << name << ": " << ::getpid() << endl;
sleep(1);
}
}
void* callback2(void* args)
{
string name = (char*)args;
while (true)
{
cout << name << ": " << ::getpid() << endl;
sleep(1);
}
}
int main()
{
// std::thread t([](){
// while(true)
// {
// cout << "线程运行起来啦" << endl;
// sleep(1);
// }
// });
// 等待就可以了
// t.join();
pthread_t tid1;
pthread_t tid2;
pthread_create(&tid1, nullptr, callback1, (void*)"thread 1");
pthread_create(&tid2, nullptr, callback2, (void*)"thread 2");
while (true)
{
cout << "我是主线程...: " << ::getpid() << endl;
sleep(1);
}
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
return 0;
}
创建线程后,像之前那样编译源文件是不行的,因为要链接线程库:
pthread
库是和Linux强相关的库,原生线程库,在用户层实现的线程实现的一种线程实现接口。首先我们来查看一下进程:
只看到了一个进程,但是我们有三个执行流在跑,怎么只是看到了一个?
ps axj
选项是查进程的所以只能查一个。查看线程:
LWP
的缩写代表Lightweight Process
,它意味着轻量级进程。LWP
和PID
是相等的,那么就是主线程,俗称进程。PID
是一样的,说明是在同一个进程内的三个执行流。多个线程谁先运行也不确定,完全是调度器自己决定。
C++11里的多线程和操作系统底层的原生线程库是封装关系。
字符常量不可被修改曾经是怎么加载到内存中的呢?
如果不可被修改,那么曾经是怎样加载到内存里的呢?
RW
,如果是字符串是R
(只读的)。MMU
也叫做内存管理单元,这个硬件结合页表中读取的数据,就会发生异常。语言层有些字符串是常量的,代码是只读属性是如何保证的,根本原因是因为在转化过程中拦截了。
从用户空间到内核空间的映射是由页表来完成的:
UK
来确认当前指向的内容是内核代码还是用户代码。UK
用来区分进程用的是内核级页表还是用户级页表。页表有多大:
2^32
个条目。2 ^ 32 * 8 Byte
= 32 GB
。操作系统通常使用多级页表(Multilevel Page Table)以实现虚拟内存管理:
10+10+12
。文件系统和物理内存进行IO
的时候,IO
的基本单位默认是4KB
。
以4G
B物理内存为例,每个页框4KB
,那么一共有,4GB / 4KB = 1024 * 1024 = 2^20
个页框。
操作系统要将页框管理起来:
struct page
的结构体中描述页框。struct page mem[1024 * 1024]
中管理。虚拟地址编译,也划分好了4KB:
4KB
为单位可以整体加载。4KB
为单位加载到内存当中。页表中的page起始地址,只记录了某个page,不关心页内细节:
物理内存一般4GB
,一个页框是4KB
,那么内存一共被划分成了2^20
个页框。
虚拟地址后12位:
2^12
次方个地址。4KB = 2^12B
,所以虚拟地址后12位
将一整个页框所有地址全部覆盖了。页表中的Page帧地址是用于标识物理内存中每个Page框的编号的。
页表只需要映射到page就不需要映射了,拿虚拟地址后12位做偏移量的:
用虚拟地址找page,再根据虚拟地址找页内偏移量来找到的。
page命中:
所以CPU就找到了对应的数据,然后就读取里面的数据,此时这里的数据就会被CPU再次拿到,CPU做计算等操作,如果还有寻址指令,那就再回过头,再重复刚刚的过程。
这样做的优点:
20B * (2 ^ 32 / 2 ^ 12) = 20B * 2 ^ 20 = 20B * 1M = 20MB
表映射是通过MMU(内存管理单元)来实现的,软(表)硬件(MMU)结合的方式。
I/O
操作结束的同时,程序可执行其他的计算任务。I/O
密集型应用,为了提高性能,将I/O
操作重叠。线程可以同时等待不同的I/O
操作。性能损失、健壮性降低、缺乏访问控制、编程难度提高。