目录
Linux线程概念
什么是线程
1.在⼀个程序⾥的⼀个执行路线就叫做线程(thread)。更准确的定义是:线程是“⼀个进程内部
的控制序列”
2.⼀切进程至少都有⼀个执行线程
3.线程在进程内部运行,本质是在进程地址空间内运行
4.在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
5.透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程控制流
分页式存储管理
虚拟地址和页表的由来
如果在没有虚拟内存和分页机制的情况下,每一个用户程序在物理内存上所对应的空间必须是连续的
但是不同的程序他的代码与数据长度都是不一样的,并且一直会有进程退出,如果采用连续内存的方式就会导致存在很多的内存碎片。
因此,我们希望操作系统提供给用户的空间必须是连续的,但是物理内存最好不要连续。
由此,虚拟地址与页表就诞生了
把物理内存按照⼀个固定的长度的页框进行分割,有时叫做物理页。每个页框包含⼀个物理页
⼀个页的大小等于页框的大小。大多数 32位 体系结构支持4KB的页,而64位体系结
构⼀般会支持 8KB 的页。
页:一个数据块,存放于页框或磁盘上
页框:一个存储区域
有了这种机制,CPU便并非是直接访问物理内存地址,而是通过虚拟地址空间来间接的访问物理内存地址。所谓的虚拟地址空间,是操作系统为每⼀个正在执行的进程分配的⼀个逻辑地址。
操作系统通过将虚拟地址空间和物理内存地址之间建立映射关系,也就是页表,这张表上记录了每⼀对页和页框的映射关系,能让CPU间接的访问物理内存地址。
总结⼀下,其思想是将虚拟内存下的逻辑地址空间分为若⼲页,将物理内存空间分为若⼲页框,通过页表便能把连续的虚拟内存,映射到若干个不连续的物理内存页。这样就解决了使⽤连续的物理内存造成的碎片问题。
物理内存管理
假设⼀个可⽤的物理内存有4GB的空间。按照⼀个页框的大小4KB进行划分, 4GB的空间就是4GB/4KB = 1048576个页框。有这么多的物理页,操作系统肯定是要将其管理起来的,操作系统需要知道哪些页正在被使用,哪些页空闲等等。
内核用 struct page 结构表示系统中的每个物理页,出于节省内存的考虑, struct page 中使
用了大量的联合体union
注意的是 struct page 与物理页相关,而并非与虚拟页相关。⽽系统中的每个物理页都要分配⼀
个这样的结构体,让我们来算算对所有这些页都这么做,到底要消耗掉多少内存。
我们算一个struct page 40个字节,一个页4kb,那么一个struct page的总数大概为页总数的1/100。
系统4GB的空间,struct page大概占40M,相对于系统的4GB内存来说并不算多
要知道的是,页的大小对于内存利用和系统开销来说非常重要,页太大,页必然会剩余较大不能利用的空间(页内碎片)。页太小,虽然可以减小页内碎片的大小,但是页太多,会使得页表太长而占用内存,同时系统频繁地进行页转化,加重系统开销。因此,页的大小应该适中,通常512B -8KB ,windows系统的页框大小为4KB。
页表
页表中的每⼀个表项,指向⼀个物理页的开始地址。在 32 位系统中,虚拟内存的最大空间是 4GB ,这是每⼀个用户程序都拥有的虚拟内存空间。既然需要让 4GB 的虚拟内存全部可用,那么页表中就需要能够表示这所有的 4GB 空间,那么就一共需要 4GB/4KB = 1048576 个表项。
虚拟内存仍然是连续的,图中的虚线只是用来表示虚拟内存单元与页表每一个表项的映射关系
最终实现,虚拟地址上连续,物理地址上分散,并且解决了内存碎片化的问题
提问
在 32 位系统中,地址的长度是 4 个字节,那么页表中的每⼀个表项就是占用 4 个字节。所以页表占据的总空间大小就是: 1048576*4 = 4MB的大小。也就是说映射表自己本身,就要占用4MB /4KB = 1024 个物理页。这会存在哪些问题呢?
1.我们创建页表的目的就是为了将进程划分为可以一个个页,可以不用连续的存放在物理地址。
但是我们页表自己就需要1024个连续物理页,与我们一开始的想法冲突
2.很多时候进程都是需要访问部分物理页,没有必要让所有物理页都一直占据内存空间
解答
解决大容量页表的方法就是将页表也看作文件,对页表也进行分页,因此,多级页表的思想就产生了。
将一个大页表拆成1024个小页表(每个表1024个表项),这样,1024(表的个数)*1024(每个表中表项)个4k的小页表同样可以占据4GB的物理内存空间。
从总数上看,整张大页表仍然需要4M空间,似乎和之前没区别,但实际上一个应用程序不可能占据4GB的所有内存空间,或许几十个小页表就够了,一个程序的代码段,数据段,栈段一共需要10M,也就是3张小页表就够了
缺页异常
CPU给MMU的虚拟地址,在 TLB 和页表都没有找到对应的物理页,该怎么办呢?其实这就是缺页异常 Page Fault ,它是⼀个由硬件中断触发的可以由软件逻辑纠正的错误。
由于CPU没有数据就无法进行计算,CPU罢工了,用户进程也就出现了缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的Page Fault Handler处理。
缺页中断会交给 PageFaultHandler 处理,其根据缺页中断的不同类型会进行不同的处理:
1.Hard Page Fault 也被称为 Major Page Fault ,翻译为硬缺页错误/主要缺页错误,这
时物理内存中没有对应的物理页,需要CPU打开磁盘设备读取到物理内存中,再让MMU建立虚拟
地址和物理地址的映射。
2.Soft Page Fault 也被称为 Minor Page Fault ,翻译为软缺页错误/次要缺页错误,这
时物理内存中是存在对应物理页的,只不过可能是其他进程调入的,发出缺页异常的进程不知道
而已,此时MMU只需要建立映射即可,无需从磁盘读取写⼊内存,⼀般出现在多进程共享内存区
域。
3.Invalid Page Fault 翻译为无效缺页错误,比如进程访问的内存地址越界访问,右比如对
空指针解引用内核就会报 segment fault 错误中断进程直接挂掉。
线程的优点
1.创建一个新线程代价比创建一个新进程代价小得多
2.与进程切换相比,线程切换操作系统要做的工作也小很多
例如:1.由于线程属于同一个进程,拥有相同的虚拟地址空间。2.进程上下文的切换会扰乱处理器的缓存机制,这将导致内存的访问在一段时间内效率很低,在线程的切换中就没有这个问题。
3.线程占用的资源要比进程小很多
4.能充分利用多处理器的可并行数量
5.在等待慢速IO工作完成的同时也可以执行计算任务
6.计算密集型应用为了在多处理器系统上运行,将计算分解到多线程中运行
7.IO密集型应用,为了提高性能将I/O操作重叠。线程可同时等待不同的I/O操作
线程的缺点
性能损失
⼀个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同⼀个处理器。如果计
算密集型线程的数量⽐可⽤的处理器多,那么可能会有较大的性能损失,这⾥的性能损失指
的是增加了额外的同步和调度开销,而可用的资源不变。
健壮性降低
编写多线程需要更全⾯更深⼊的考虑,在⼀个多线程程序里,因时间分配上的细微偏差或者
因共享了不该共享的变量⽽造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护
的。
缺乏访问控制
进程是访问控制的基本粒度,在⼀个线程中调用某些OS函数会对整个进程造成影响。
编程难度提高
线程异常
一个进程中的多个线程同属于该进程,那就意味着一旦某个进程出现异常例如野指针导致的不仅仅是那个线程退出,还是整个进程的退出。因为线程出了异常就是进程出异常。
Linux进程VS线程
进程与线程
1.进程是资源分配的基本单位(所有线程共享进程资源)
2.线程是调度的基本单位(这意味着每个线程分配到的时间片和进程本身无关)
3.线程共享一部分数据,同时还拥有自己的一部分数据:
线程ID、一组寄存器、栈、errno、信号屏蔽字、调度优先级
进程的多个线程共享
同⼀地址空间,因此Text Segment、Data Segment都是共享的,如果定义⼀个函数,在各线程中都可以调用,如果定义⼀个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
文件描述符表、每种信号的处理方式、当前工作目录、用户ID与组ID
进程与线程关系如图
Linux线程控制
POSIX线程库
1.与线程有关的函数构成了⼀个完整的系列,绝大多数函数的名字都是以“pthread_”打头的
2.要使用这些函数库,要引入头文件<pthread.h>
3.连接这些函数库需要使用编译器命令的 -lpthread选项,也就是使用系统路径下的libpthread.so 动态库,头文件与库文件都在系统默认路径下
创建线程
#include<pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *
(*start_routine)(void*), void *arg);
thread:返回线程ID
attr:设置线程属性,设置为nullptr则采用默认配置
start_routine:是个函数地址,线程启动后要执⾏的函数
arg:传给线程启动函数的参数
返回值:
成功返回0
错误返回错误码
测试
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<pthread.h>
#include<string.h>
void* run(void* arg)
{
int i=0;
while(true)
{
i++;
printf("我是线程1:%d\n",i);
sleep(1);
}
}
int main()
{
pthread_t tid;
int ret;
if(ret=pthread_create(&tid,nullptr,run,nullptr)!=0)
{
fprintf(stderr,"pthread create:%s",strerror(ret));
exit(EXIT_FAILURE);
}
while(true)
{
printf("我是主线程\n");
sleep(1);
}
}
获取线程ID
#include<pthread.h>
pthread_t pthread_self(void);
这个函数返回“进程级线程ID”
这个“ID”是pthread库给每个线程定义的进程内唯⼀标识,是pthread库维持的
当然了,这个ID是进程级的,操作系统并不认识
其实pthread库也是通过内核提供的系统调⽤(例如clone)来创建线程的,而内核会为每个线程创建系统全局唯⼀的“ID”来唯⼀标识这个线程。
-L 选项:打印线程信息
我们可以看到,两个线程拥有相同的进程ID,但是操作系统为他们分配了不同的“LWP”(即系统级线程ID)
LWP得到的是真正的线程ID。之前使⽤pthread_self得到的这个数实际上是⼀个地址,在虚拟地址空间上的⼀个地址,通过这个地址,可以找到关于这个线程的基本信息,包括线程ID,线程栈,寄存器等属性。
ps -aL看到的线程ID,有⼀个线程ID和进程ID相同,这个线程就是主线程,主线程的栈在虚拟
地址空间的栈上,而其他线程的栈在是在共享区(堆栈之间),因为pthread系列函数都是pthread库提供给我们的。而pthread库是在共享区的。所以除了主线程之外的其他线程的栈都在共享区。
线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
1. 从线程函数return。这种方法对主线程不适用,从main函数return相当于调⽤exit。
2. 线程可以调pthread_exit终止自己
3. ⼀个线程可以调⽤pthread_cancel终⽌同⼀进程中的另⼀个线程。
pthread_exit函数
功能:线程终止
void pthread_exit(void *value_ptr);
value_ptr:value_ptr不要指向⼀个局部变量,因为这个返回值是要交给其他线程查看的,
如果指向局部变量那么线程结束时,局部变量也会被一起销毁
因此,value_ptr最好是全局变量的地址或者是堆上开辟的空间
⽆返回值,跟进程⼀样,线程结束的时候⽆法返回到它的调⽤者(⾃⾝)
如果采用return返回,那么要求也和pthread_exit相同,不要指向一个局部变量
功能:取消⼀个执⾏中的线程
int pthread_cancel(pthread_t thread);
thread:线程ID
返回值:
成功返回0
失败返回错误码
线程等待
为什么需要线程等待
已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
创建新的线程不会复用刚才退出线程的地址空间。
这里其实和进程等待差不多,如果不等待的话那么线程也会一直占有空间不释放,相当于“僵尸线程”
功能:等待线程结束
int pthread_join(pthread_t thread, void **value_ptr);
thread:指定线程ID
value_ptr:因为线程的返回值是一个一级指针,
所以我们就需要使用二级指针才能拿到线程退出时返回的值
返回值:
成功返回0
失败返回错误码
调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过
pthread_join得到的终止状态是不同的,总结如下:
1.使用return 或pthread_exit函数退出,我们知道exit和return非常类似,所以使用这两种方式退出时,value_ptr指针上的值就是return的返回值或时传给pthread_exit函数的参数。
2.如果线程是被别的线程使用pthread_cancel终止掉的,value_ptr所指向的单元里存放的是
就为常数PTHREAD_CANCELED
3.如果对thread线程的终止状态不感兴趣,可以传NULL给value_ptr参数
测试
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
void *thread1(void *arg)//1号线程return返回,在堆上开辟空间带出返回值
{
printf("thread 1 returning ... \n");
int *p = (int *)malloc(sizeof(int));
*p = 1;
return (void *)p;
}
void *thread2(void *arg)//2号线程pthread_exit返回,一样在堆上开辟空间
{
printf("thread 2 exiting ...\n");
int *p = (int *)malloc(sizeof(int));
*p = 2;
pthread_exit((void *)p);
}
void *thread3(void *arg)//3号线程被主线程pthread_cancel返回,返回值默认为常数PTHREAD_CANCELED
{
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 %lX, 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 %lX, 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 %lX, return code:PTHREAD_CANCELED\n",
tid);
else
printf("thread return, thread id %lX, return code:NULL\n", tid);
}
分离线程
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则
无法释放资源,从而造成系统泄漏。
如果不关心线程的返回值,join是⼀种负担,这个时候,我们可以告诉系统,当线程退出时,⾃
动释放线程资源。
功能:分离线程,线程退出时自动释放资源
int pthread_detach(pthread_t thread);
thread:指定线程ID
返回值:
成功返回0
失败返回错误码
我们可以选择让线程自己分离自己,也可以选择让其他线程来进行分离
thread_detach(pthread_self());
thread_datach(thread_id);
joinable和分离是冲突的,⼀个线程不能既是joinable又是分离的。
测试
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
void *thread_run(void *arg)
{
pthread_detach(pthread_self());
printf("%s\n", (char *)arg);
return NULL;
}
int main(void)
{
pthread_t tid;
if (pthread_create(&tid, NULL, thread_run, (void*)"thread1 run...") != 0)
{
printf("create thread error\n");
return 1;
}
int ret = 0;
sleep(1); // 很重要,要让线程先分离,再等待
if (pthread_join(tid, NULL) == 0)
{
printf("pthread wait success\n");
ret = 0;
}
else
{
printf("pthread wait failed\n");
ret = 1;
}
return ret;
}
线程ID及进程地址空间布局
pthread_create函数会产生⼀个进程级线程ID,存放在第⼀个参数指向的地址中。
前面讲的系统级线程ID属于进程调度的范畴,因为线程是轻量级进程,是操作系统调度的最小单位,所以需要唯一的一个数值来表示该线程
pthread_create函数第⼀个参数指向⼀个虚拟内存单元,该内存单元的地址即为新创建线程的线
程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
线程库NPTL提供了pthread_self函数,可以获得线程自身的ID:
pthread_t pthread_self(void);
pthread_t 类型取决于实现,对于linux目前的NPTL而言,pthread_t类型的线程ID实际上,本质上是进程地址空间上的一个地址。
线程封装
#pragma once
#include <iostream>
#include <pthread.h>
#include <functional>
#include <string>
#include <cstdint>
namespace ThreadModule
{
std::uint32_t cnt; // 原子计数器,形成线程名称
class Thread
{
public:
using work_t = std::function<void()>;
enum class TSTATUS
{
THREAD_NEW,
THREAD_RUNNING,
THREAD_STOP
};
Thread(work_t work) : _status(TSTATUS::THREAD_NEW),
_joined(true),
_func(work)
{
SetName();
}
void EnableJoined()
{
if (_status == TSTATUS::THREAD_NEW)
_joined = true;
}
void EnableDetach()
{
if (_status == TSTATUS::THREAD_NEW)
_joined = false;
}
bool Start()
{
if (_status == TSTATUS::THREAD_RUNNING)
return true;
int n = pthread_create(&_id, nullptr, Run, (void *)this);
if (n != 0)
return false;
return true;
}
bool Join()
{
if (_joined)
{
int n = pthread_join(_id, nullptr);
if (n != 0)
return false;
return true;
}
return _status==TSTATUS::THREAD_STOP;
}
~Thread() {}
private:
static void *Run(void *obj)
{
Thread *self = static_cast<Thread *>(obj);
self->_status = TSTATUS::THREAD_RUNNING;
pthread_setname_np(self->_id, self->_name.c_str()); // 为当前线程取名字
if (self->_joined == false)
pthread_detach(pthread_self());
self->_func();
self->_status=TSTATUS::THREAD_STOP;
return nullptr;
}
void SetName()
{
_name = "Thread-" + std::to_string(cnt++);
}
private:
std::string _name;
pthread_t _id;
TSTATUS _status;
bool _joined;
work_t _func;
};
}
测试
#include"thread.hpp"
#include<vector>
void test_printf(int num)
{
std::cout<<num;
}
int main()
{
std::vector<ThreadModule::Thread> vec;
for(int i=0;i<10;++i)
{
auto func=std::bind(test_printf,i);
vec.emplace_back(func);
}
for(auto& e:vec)
e.Start();
for(auto& e:vec)
e.Join();
std::cout<<std::endl;
return 0;
}