【Linux】线程篇Ⅰ:线程和task_struct 执行流的理解、相关接口命令、线程异常、线程的私有和共享


一、概念

0. 线程

  • 线程是一个执行分支,执行力度比进程更细,调度成本更低
  • 线程是 进程内部的一个执行流
内核观点:
线程 是 CPU 调度的基本单位
进程 是承担分配系统资源的基本实体

概念理解:

  • 调度成本更低,指的是,相较于进程,线程不再需要进行对 cache(高速缓存)

  • 进程是承担分配系统资源的基本实体:进程是一系列资源的集合,包括至少一个 task_struct(执行流)、包括虚拟地址空间、页表、自己的代码和数据等等…内容。

  • 把进程从磁盘加载到内存,也就是加载可执行程序形成进程,可以换一个说法了:把该可执行程序,加载到内存,让 OS 为该进程申请与该进程匹配的所有资源。

  • 之前提到的进程可以理解为:内部只有一个 task_struct(执行流) 的进程。

在这里插入图片描述

操作系统是需要对这么多线程管理的,有些操作系统设计了 TCB (thread control block,线程控制块,属于进程 PCB),调度进程、线程都有各自的调度方法。Windows 就是这样设计的。

  • Linux 内核的设计:复用 PCB 的结构体,用 PCB 模拟线程的 TCB。也就是说,Linux 没有真正意义上的线程,而是用进程方案模拟的线程。

  • 复用代码和结构,使得 Linux 系统更简单,好维护效率更高,也更安全。这也是 Linux 可以不间断的运行的原因,因为一款 OS 操作系统,使用最频繁的功能,除了 OS 本身,接下来就是进程了

每个 pcb 叫做 轻量级进程(light weight process),查询这些线程时,出现的 LWP 就叫做 轻量级进程id

PID 和 LWP 相等,就说明是主线程。操作系统调度的时候,其实不是用 PID 识别进程,而 是用 LWP 进行识别的

1. 线程的优缺点

优点:

  • 创建一个新线程的代价要比创建一个新进程小得多
  • 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
  • 线程占用的资源要比进程少很多
  • 能充分利用多处理器的可并行数量
  • 在等待慢速 I/O 操作结束的同时,程序可执行其他的计算任务
  • 计算密集型应用(加密解密、与文件压缩和解压等算法有关的,使用的是 CPU 资源),为了能在多处理器系统上运行,将计算分解到多个线程中实现
  • I/O 密集型应用(下载上传、IO 主要消耗 IO 资源、磁盘的 IO、网络带宽等),为了提高性能,将 I/O 操作重叠。线程可以同时等待不同的 I/O 操作。

ps:对于计算密集型应用,线程不是越多越好,进程 和 线程,与 CPU 的个数 / 核数 一致是比较合适的。

pps:对于 I/O 密集型应用,自然也不是越多越好,但是可以比较多,这个“比较”无法量化,需要结合具体场景进行试验来确定。

缺点:

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

2. 页框和页帧

OS 在和磁盘这样的设备进行 IO 交互的时候,绝对不是按照字节为单位的,而是按照 为单位。而磁盘这样的外设工作速度是很慢的,页框 和 页帧 的设计,包括 局部性原理,可以大大提高 IO 的效率

这里的块 我们一般做 4kb 考虑,这是文件系统的要求,具体和 OS 有关。

可以说内存管理的本质,是决定将磁盘中特定的 4kb 块(数据内容)放入到哪一个物理内存的 4kb 空间(数据保存的空间)。

  • 首先 文件系统 + 编译器:注定了文件(可执行程序 + 动态库)在磁盘的时候就是以块(4kb)为单位的。

  • 其次 操作系统 + 内存:内存实际在进程内存管理的时候,也要以 4kb 为单位。

这样 物理内存 中的每 4kb 称作 页page,也叫页框
磁盘 中的每 4kb 称作 页帧

在这里插入图片描述

这其中的 页page,会有一个个 struct page{}; 进行状态标识等,里面属性非常少。这些 page 结构体,会由类似 struct page mem[1,048,576] 这样的数组进行管理,刚好数组下标可以访问到物理内存的每一个页框。

局部性原理:OS 会提前加载正在访问的数据相邻或者附近的数据。我们通过预先加载要访问的数据的附近的数据,来减少未来 IO 的次数,多加载数据进来的本质,就叫做数据的预加载

3. 页表的设计、虚拟地址的解析方式、以及块为什么设计成 4kb

页表储存的核心功能是映射 虚拟地址 和 物理内存 的位置关系,要了解页表的设置方法,首先要知道:虚拟地址不是整体被使用的,而是按照 10 + 10 + 12 比特进行划分的。

页表:页目录 + 页表项,(32 位系统为二级页表方案,64 位系统为三级页表)具体如图示。

  • 虚拟地址的最高 10 位,以供寻找页目录对应的页表项

  • 虚拟地址的次高 10 位,供页表项找到对应的物理内存的页框

  • 虚拟地址的低 12 位,访问一个块的 4kb 中的具体哪一个字节

      虚拟地址对应的物理内存的定位 = 对应页框的起始地址 + 虚拟地址的低 12 个比特位对应的地址数据
      即:定位任意一个内训字节位置 = 页框 + 页内偏移
      		= 基地址 + 偏移量
    
      2^12:0000 0000 0000 ~ 1111 1111 1111
      		[0, 4095]
    

在这里插入图片描述

估算页表的可预见大小:

2^20 = 1MB
1MB * 4  = 4MB 

但是页表不会全部创建,就注定了页表的实际大小远远小于 4MB。

4. 对进程的一些整体理解

我们在实际 malloc 申请内存的时候,OS 只需要在虚拟地址空间上申请就行了,当我们真正访问的时候,OS 查到没有相应页表,向寄存器 MMU 发送缺页中断,OS 接收到这一动作,就会自动去申请或填充页表,并申请具体的物理内存,接着再继续跑我们后面的代码。

我们知道上述字符常量区的数据是只允许读取,不允许修改的。

char *s = "hello world";
*s = 'H';	// err...

原因是,s 里面保存的是指向字符的虚拟起始地址,对其寻址的时候,必定会伴随虚拟到物理的转化(MMU + 查页表的方式),如果对这个操作进行权限审查,就会发现我们只有读权限即写操作是非法的。此时 MMU 发生异常,OS 识别异常,异常转化成信号发送给目标进程,进程在从内核转化成用户态的时候,进行信号处理,收到了终止进程的信号,至此进程被终止。
在这里插入图片描述


二、一些接口 和 命令

1. ps -aL - - 查看执行流

# 查看文件执行流
ps -aL | grep [可执行程序]
# 带上表头,查看文件执行流
ps -aL | head -l && ps -aL | grep [可执行程序]

2. Linux 原生系统库接口

2.1 pthread_create 函数:创建线程

#include <pthread.h>

注意:使用本章函数,需要在编译时,声明库,即带上 -lpthread

  int pthread_create(pthread_t *thread, const pthread_attr_t *attr, 
  					void *(*start_routine) (void *), void *arg);

参数 thread:

  • 创建线程的地址

参数 attr:

  • 线程属性,一般设成 nullptr

参数 start_routine:

  • 线程 thread 所要执行的函数

参数 arg:

  • 作为 strat_routine 回调函数的参数,可以传对象、字符串等等

返回值:

  • 成功返回 0,失败返回错误码。

2.2 ptread_join 线程等待

#include <pthread.h>
  int pthread_join(pthread_t thread, void **retval);

参数 thread:

  • 等待的线程

输出型参数 retval:

  • 给自己创建的 void* retval 取地址。拿到等待线程的相关结果

返回值:

  • 成功返回 0,失败返回错误码。

2.3 ptread_exit 线程退出

#include <pthread.h>
  void pthread_exit(void *retval);

输出型参数 retval:

  • 拿到等待线程的相关结果

2.4 ptread_cancel 线程取消

#include <pthread.h>
  int pthread_cancel(pthread_t thread);

参数 thread:

  • 线程名

主线程使用函数,新线程取消导致的退出,失败返回错误码为 -1( PTHREAD_CANCELED),pthread_join 可以拿到。

 #define PTHREAD_CANCELED (void *)-1

2.5 ptread_self 线程名称

#include <pthread.h>
  pthread_t pthread_self(void);

返回值:

  • 调用这个函数的线程的线程 id

2.6 pthread_detach 线程分离

一个线程如果被分离,就无法再被 join,如果 join,函数会报错

#include <pthread.h>
  int pthread_detach(pthread_t thread);

参数 thread:

  • 需要被分离的线程名

返回值:

  • 成功返回 0,失败返回错误码

2.7 使用举例

🌰使用举例 1:

#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <ctime>
using namespace std;

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*)2); 

	// 线程被取消会将 -1 传给参数 ret
    // PTHREAD_CANCELED; #define PTHREAD_CANCELED ((void *) -1)
}

int main()
{
	// 1. 创建线程
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRun, (void*)"thread 1");
    // 2. 取消线程
    sleep(3);
   	pthread_cancel(tid);
   	// 3. 等待线程
    void *ret = nullptr;
    pthread_join(tid, &ret);
    cout << " new thread exit : " << (int64_t)ret << "; quit thread: " << tid << endl;
    return 0;
}

输出结果:

在这里插入图片描述

🌰使用举例 2:创建多个线程,每个线程执行 [1, top] 的求和计算

#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>
#include <ctime>
using namespace std;

#define NUM 10

enum{ OK=0, ERROR };

class ThreadData
{
public:
    ThreadData(const string &name, int id, time_t createTime, int top)
    :_name(name), 
    _id(id), 
    _createTime((uint64_t)createTime),
    _status(OK), 
    _top(top),
     _result(0)
    {}
    
    ~ThreadData()
    {}
    
public:
    // 输入的
    string _name;
    int _id;
    uint64_t _createTime;

    // 返回的
    int _status;
    int _top;
    int _result;
};


// 线程终止
// 1. 线程函数执行完毕,return void*
// 2. pthread_exit(void*)
// 3. pthread_cancel(int id)
void *thread_run(void *args)
{
    ThreadData *td = static_cast<ThreadData *>(args);   // static_case<>:类型强转,和括号强转一个意思,但是更安全

    for(int i = 1; i <= td->_top; i++)
    {
        td->_result += i;
    }
    cout << td->_name << " cal done!" << endl;
    
    pthread_exit(td);
    //return td;
}

int main()
{
    // 线程创建
    pthread_t tids[NUM];
    for(int i = 0; i < NUM ;i++)
    {
        char tname[64];
        snprintf(tname, 64, "thread-%d", i+1);
        ThreadData *td = new ThreadData(tname, i+1, time(nullptr), 100+5*i);
        pthread_create(tids+i, nullptr, thread_run, td);
        sleep(1);
    }


    // 线程等待(并获取新线程退出信息)
    void *ret = nullptr; // int a =  10
    
    for(int i = 0 ; i< NUM; i++)
    {
        int n = pthread_join(tids[i], &ret);
        if(n != 0) cerr << "pthread_join error" << endl;
        ThreadData *td = static_cast<ThreadData *>(ret);
        if(td->_status == OK)
        {
            cout << td->_name << " 计算的结果是: " << td->_result << " (它要计算的是[1, " << td->_top << "])" <<endl;
        }

        delete td;
    }

    cout << "all thread quit..." << endl;
    return 0;
    while (true)
    {
        cout << "main thread running, new thread id : " << endl;
        sleep(1);
    }
}

3. C++ 语言接口

上述已经涉及到的接口,是原生系统库的系统级解决方案,虽然在库中实现但是跟语言一样,比语言更靠近底层罢了。而 C++ 其实是对上述线程库做的封装,所以在 Linux 编译使用时还得标记 -lpthread 库文件。

🌰使用举例:

void run1(){}
void run2(){}

int main()
{
	// 创建线程
	thread t1(run1);
	thread t2(run2);

	// 等待线程
	t1.join();
	t2.join();

	return 0
}

ps:虽然原生接口效率更高,但是 语言接口 有跨平台性。不确定只在 Linux 下跑的还是推荐使用语言接口。


三、线程异常

  1. 多线程程序中,任何一个线程崩溃了,最后都会导致进程奔溃现象
  • 系统角度:线程是进程的执行分支,线程挂了进程就也挂了。
  • 信号角度:页表转换的时候,MMU识别写入权限的,没有验证通过。MMU 产生异常,0S识别并给进程发信号。之所以称作 Linux 进程信号,是以进程为主的。
  1. 因为执行流看到的资源是通过地址空间看到的,多个 LWP 看到的是同一个地址空间。所以,所有的线程可能会共享进程的大部分资源。也就是我们所说的线程是缺乏访问控制的。

🌰使用举例:

makefile:创建线程,需要在编译的时候导入线程库,添加 -lpthread

threadTest:thread.cc
	g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
	rm -f threadTest
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>
#include <ctime>

using namespace std;

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;
}

四、进程 VS 线程

进程是资源分配的基本单位
线程是调度的基本单位

1. 线程各自所有

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

  • 线程ID
  • 一组寄存器(线程是要被切换的,需要各自的上下文)
  • (线程各自的零食变量不能相互干扰)
  • errno
  • 信号屏蔽字
  • 调度优先级

ps:关于各自所有寄存器和栈的深度理解,见 🔗线程篇Ⅱ

2. 线程共享部分

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

  • 文件描述符表

  • 每种信号的处理方式(SIG_ IGN、SIG_ DFL 或者自定义的信号处理函数)

  • 当前工作目录

  • 用户id和组id


🔗接下篇【线程篇Ⅱ】

👉🔗线程篇Ⅱ:线程库和线程id、线程的互斥与同步


  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值