1.线程概念

1.1 理解线程

在这里插入图片描述

要理解线程,直接从进程入手,每个进程都是一个个得task_struct,当我们需要执行一个新任务时,操作系统以进程为单位,给我们分配资源,但是有时候执行得任务不需要重新调配资源那么麻烦,就需要线程了

对于Linux系统,并没有真正意义上的线程,我们将线程称作轻量级进程,地址空间作为进程的资源窗口,线程就可以看作进程里的一个个执行流,共享资源各自执行各自的执行流

简单类比: 进程像独立的 “工厂车间”(有专属内存、文件句柄等资源),线程像车间里的 “工人”(共享车间资源,只占少量执行上下文),工人干活(执行任务)的粒度更细,但必须依托车间存在

1.2 重新定义线程和进程

线程: 操作系统的调度基本单位,是进程内部的执行流资源
进程: 进程是承担系统资源分配的基本实体

1.3 进程地址空间第四讲

在这里插入图片描述

在理解进程地址空间时,我们并不知道页表的转换机制,那么虚拟地址是如何转换到物理地址的呢?

在这里插入图片描述

这里我们以 32 位的虚拟地址为例,那么通常将其分为 32 = 10 + 10 + 12,第一个 10 位表示一级页目录(通常页目录有 1024 个条目),每个条目存放一个二级页表的条目地址,第二个 10 位就表示二级页表的条目地址,二级页表的条目里存放的是物理内存页框的起始地址,最后通过页框加上最后 12 位的偏移量查找到特定地址内容,由于是 12 位地址(大约 2¹² 的大小),恰好符合页框的 4KB 大小,所以偏移量不存在超出页框的情况

对于一个变量 int a = 10,是 4 个字节,难道要拿四个地址吗,其实不是的,只需要找到其实地址,编译器本身就知道内置类型的偏移量(自定义类型也是由内置类型组成的),所以起始地址+类型就能知道整个变量的内容了

单级页表对连续内存要求高,于是引入了多级页表,但是多级页表也是一把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率

在这里插入图片描述

有没有提升效率的办法呢?计算机科学中,很多问题都能通过添加中间层解决,MMU(内存管理单元)就引入了这样一个“新武器”—— TLB(转译后备缓冲器,Translation Lookaside Buffer,江湖人称快表,本质是一种缓存)

具体工作流程如下:

  1. CPUMMU 传输新的虚拟地址后,MMU 会优先查询 TLB 中是否存在对应的映射关系
  2. TLB 中存在该映射(即 Cache Hit),则直接获取物理地址,通过总线发送给内存,快速完成地址转译
  3. 由于 TLB 容量较小,难免出现缓存未命中(Cache Miss)的情况。此时 MMU 会启用“老武器”页表,在页表中查找对应的物理地址
  4. 找到物理地址后,MMU 除了将地址通过总线传给内存,还会把该虚拟地址与物理地址的映射关系写入 TLB,完成缓存刷新,方便后续快速查询

1.4 进程VS线程

线程共享进程数据,但也拥有自己的⼀部分"私有"数据:

  • 线程 ID
  • ⼀组寄存器,线程的上下文数据
  • errno
  • 信号屏蔽字
  • 调度优先级

在这里插入图片描述

同一地址空间,因此 Text SegmentData Segment 都是共享的,如果定义⼀个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:

  • 文件描述符表
  • 每种信号的处理方式(SIG_ IGNSIG_ DFL 或者⾃定义的信号处理函数)
  • 当前工作目录
  • 用户 id 和组 id

2.线程控制

2.1 线程的链接与查看

mythread:mythread.cpp
	g++ -o $@ $^ -lpthread

.PHONY:clean
clean:
	rm -f mythread

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

在这里插入图片描述

ldd 命令查看确实链接上了,那么如何查看线程呢?

在这里插入图片描述

使用 ps 命令带上 -L 就表示查看 LWP,这是轻量级进程的缩写,LWPid 也叫 tid,还可以通过 pthread 库中有函数 pthread_self() 得到,它返回⼀个 pthread_t 类型的变量

  • pthread_self() 得到的是「用户态 TID」:属于 POSIX 线程库层面,仅当前进程内唯一,类型是 pthread_t (可能是指针),用于用户态线程间识别
  • ps 查到的 LWPLight Weight Process)是「内核态 TID」:属于操作系统内核层面,全系统唯一,是整数(如 12345),是内核调度的实际标识

2.2 创建线程

在这里插入图片描述

参数:

  • thread:返回线程 ID
  • attr:设置线程的属性,attrNULL 表示使用默认属性
  • start_routine:是个函数地址,线程启动后要执行的函数
  • arg:传给线程启动函数的参数

返回值: 成功返回 0;失败返回错误码

pthreads 函数出错时不会设置全局变量 errno(而大部分其他 POSIX 函数会这样做)。而是将错
误代码通过返回值返回

2.3 终止线程

在这里插入图片描述
在这里插入图片描述

线程退出有三种方法:

  1. 线程可通过从线程函数 return 的方式终止自身
  2. ⼀个线程可以调用 pthread_ cancel() 终止同一进程中的另一个线程,一般由主线程来调用
  3. 线程也可主动调用 pthread_exit() 终止自身

🔥值得注意的是: pthread_exit 或者 return 返回的指针所指向的内存单元必须是全局的或者是用 malloc 分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时该线程函数已经退出了

2.4 等待线程

在这里插入图片描述

参数:

  • thread:线程 ID
  • value_ptr:它指向⼀个指针,后者指向线程的返回值

返回值: 成功返回 0;失败返回错误码

阻塞调用方(一般是主线程),默认是阻塞等待,等待目标线程退出,然后回收其内核资源(如线程描述符),并获取退出状态。其实和进程等待的 waitpid 很像

维度 pthread_exit(void *retval) pthread_cancel(pthread_t thread) pthread_join(pthread_t thread, void **retval)
核心作用 线程主动终止自身(正常退出) 向目标线程发送「终止请求」(请求他杀) 阻塞当前线程,等待目标线程退出 + 回收资源 + 获取返回值
调用方 线程自身(自己退出自己) 其他线程(如主线程终止子线程) 其他线程(如主线程等待子线程)
线程状态影响 调用线程立即退出(生命周期结束) 目标线程若响应,会被终止(生命周期结束) 不影响目标线程,仅阻塞调用方线程
是否阻塞 不阻塞(调用后线程直接退出,不返回) 非阻塞(发送请求后立即返回,不等待结果) 阻塞(直到目标线程退出才返回)
返回值/结果 通过 retval 传递退出状态给 pthread_join() 返回 0 = 请求发送成功(不代表线程已退出) 返回 0 = 线程退出且资源回收成功;retval 接收目标线程退出状态
目标线程要求 无(自身调用,无需指定目标) 不能是分离线程(PTHREAD_CREATE_DETACHED 不能是分离线程(分离线程自动回收资源)
资源回收 不主动回收自身资源,需 pthread_join() 处理 不回收目标线程资源,需 pthread_join() 处理 核心职责:回收目标线程内核资源(避免僵尸线程)
典型场景 线程完成任务后正常退出 终止超时、卡住或无需继续运行的线程 确保子线程完成任务 + 回收资源 + 获取退出状态

2.5 分离线程

在这里插入图片描述

这个函数其实和 pthread_join 很像,但是这个函数是给线程定义为分离状态,让线程在执行完之后自动回收资源释放,适用于不需要拿线程返回结果的情况,joinable 和分离是冲突的,⼀个线程不能既是 joinable 又是分离的

核心区别: detach 是 “自动回收、不等待、无结果”,join 是 “手动回收、阻塞等、拿结果”

3.线程ID及地址空间布局

我们知道 pthread_self 能够返回 tid,即线程自身的 ID,也就是 pthread_create 的那个(用户态层面),可是为什么 tid 是一串很长的数字呢?

在这里插入图片描述

其实 pthread 库也是个动态库,存放于共享区里,每个线程在库里都有自己的独立栈,即 TCB,将 tid%p 打印出来就会发现和每个独立栈的地址是一样的,因此我们可知每一个线程库独立栈的起始地址就是 tid

#include <iostream>
#include <vector>
#include <pthread.h>
#include <unistd.h>

using namespace std;

#define NUM 10
//int g_val = 0;
//__thread int g_val = 0;

struct threadData
{
    string threadname;
};

string toHex(pthread_t tid)
{
    char buffer[128];
    snprintf(buffer, sizeof(buffer), "0x%lx", tid);
    return buffer;
}

void InitThreadData(threadData* td, int number)
{
    td->threadname = "thread-" + to_string(number);
}

void* threadRoutine(void* args)
{
    threadData* td = static_cast<threadData*>(args);
    int i = 0;
    while(i < 10)
    {
        cout << "pid: " << getpid() << ", tid: " << toHex(pthread_self()) << ", threadname: " << td->threadname << ", i: " << toHex((pthread_t)i) << endl;
        sleep(1);
        i++;
    }
    delete td;
    return nullptr;
}

int main()
{
    vector<pthread_t> tids;
    for(int i = 0; i < NUM; i++)
    {
        pthread_t tid;
        threadData* td = new threadData();
        InitThreadData(td, i);
        pthread_create(&tid, nullptr, threadRoutine, td);
        tids.push_back(tid);
        sleep(1);
    }
    for(int i = 0; i < tids.size(); ++i)
    {
        pthread_join(tids[i], nullptr);
    }
}

假设我们创建一个多线程,然后每个线程共用一个threadRoutine方法函数,分为三种情况:

  • 每个 threadRoutine 方法函数里都有一个 i 变量,那么查看地址可以发现,每个线程都有自己的独立栈,i 都有自己的地址,i 和线程一一对应
  • 如果不是自己的局部变量呢?那么使用全局变量int g_val = 0就是操作同一个变量了
  • 如果每个线程想要有自己的全局变量呢,可以使用 __thread int g_val = 0__thread 是个编译选项,这叫线程的局部存储,前提是只能使用内置类型,不能使用自定义类型

希望读者们多多三连支持

小编会继续更新

你们的鼓励就是我前进的动力!

请添加图片描述

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐