线程的概念和控制

目录

一、什么是线程

[1.1 引入](#1.1 引入)

[1.2 Linux线程](#1.2 Linux线程)

[1.3 理解](#1.3 理解)

二、分页式管理

[2.1 页和页框](#2.1 页和页框)

[2.2 理解页表](#2.2 理解页表)

[2.3 总结](#2.3 总结)

三、线程周边

[3.1 优点](#3.1 优点)

[3.2 异常](#3.2 异常)

[3.3 共享和私占](#3.3 共享和私占)

四、线程控制

[4.1 创建线程](#4.1 创建线程)

[4.2 线程等待](#4.2 线程等待)

[4.3 线程终止](#4.3 线程终止)

[4.4 线程分离](#4.4 线程分离)

[4.5 demo](#4.5 demo)

五、进程地址空间分布


一、什么是线程

1.1 引入

在讨论线程时离不开进程,进程是承担分配系统资源的基本实体,那线程呢?线程是CPU调度的基本单位。这么说还是很抽象,大家先看下图:

上图相信大家都很熟悉,这是进程从虚拟到物理的映射图。进程占据着系统资源比如CPU资源、内存资源等。当我们在一个程序中同时执行多个相对独立的任务时,如果采用单进程就会造成任务逻辑耦合度高,代码的可读性和可维护性会变差;采用多进程他们之间资源共享麻烦,并且每次创建和调度开销也大。所以引入了线程!

1.2 Linux线程

在Linux中是没有线程的概念!它是用进程模拟实现了线程,所以线程被称为轻量级进程(LWP)!在Windows中是存在真正意义上的线程的,其线程控制块是TCB,而Linux是复用了PCB,这就代表像调度算法等都可以复用,不用另起炉灶,增强了代码的健壮性:

每个线程的时间片差不多是一样的。

1.3 理解

我们之前了解的进程在内部都只有一个线程。线程与进程是互补的,进程是一块资源(资源本质就是虚拟地址),而线程是执行流用于执行被划分的代码段(通过"执行流的调度"来选择执行哪部分函数块,函数块本质就是虚拟地址的集合)。进程强调的是独占,部分共享;而线程强调的是共享(共享所属进程的虚拟地址空间),部分独占!

二、分页式管理

2.1 页和页框

上面我说资源就是虚拟地址,那该怎么理解呢?资源可通过虚拟地址找到,所以虚拟地址就是资源窗口。那虚拟地址是怎么找到资源的呢?

我们知道操作系统读取磁盘时一次读4KB,根据这操作系统把内存分为若干个4KB,一个4KB的空间被称为页框或物理页表示存储区域,每个页框中都包含一个页(page),页是一个数据块可以放在任何页框中,其大小也是4KB。操作系统把虚拟地址空间划分成若干个页,把物理内存划分成若干个页框然后通过页表把连续的虚拟内存映射到不连续(减少碎片化问题)的物理内存中。

在4GB的内存中页框一共有1048576个。这么多的页框需要管理起来,依然是先描述再组织!操作系统通过struct page描述页框。它里面像有flags用于表示存放页的状态比如空闲或占用等。在page里不用存放该物理页的起始地址,因为在内核中组织page的一种形式是数组我们只需要知道下标(物理页编号)然后根据起始的物理地址就可以推算出来。相反的我们如果知道物理页的地址就可以反向推出下标。

2.2 理解页表

上面我提到页表是页和页框映射关系表,那它们是怎么映射的呢?它可不是简单的物理地址与虚拟地址简单对应。在32位下一个地址长度为4个字节,一共有1048576个页即(4byte * 11048576) / 4KB = 1024个物理页。需要1024个连续的物理页这与我们实现不连续的空间有点背道而驰了,所以页表要设计成多级的。虚拟地址的使用具体到字节:

高10位表示页目录索引(页目录存放下一级页表的地址)、中10位表示页表索引(页表存放物理页的地址)、低12位页内偏移量(为什么要把页内偏移放在最后呢?其实是为了更有序)。前两个设计成10位是因为2^10 * 2^10 = 1048576正好等于页框数,后面的12位是因为2^12 = 4KB正好为页框大小。

CPU里的CR3寄存器指向页目录的物理地址,MMU通过CR3来实现虚拟到物理转换。其过程就是MMU通过CR3提供的页目录先找到页表然后再根据页表来找到对应的页框然后根据页内偏移来找到具体的物理地址然后交给CPU。这样算好像也是占用4MB虽然不连续了,但是一个程序是不可能把一个页表都给占满,一般也就占几页所以实际上占用的还是很少的。

这样做效率还是有点慢,所以引入了快表(TLB,也就是缓存)MMU会先去快表里查看有没有对应的物理地址有的话交给CPU没有的话就还是通过页表当找到物理地址时,会先把该条映射关系给刷新到块表里,然后在交给CPU。

对于申请内存,写时拷贝,缺页中断都会涉及到重新创建页表和建立新的映射关系。

2.3 总结

你拥有的资源,在安全情况下就是有多少个虚拟地址,虚拟地址就是资源的代表。资源的划分就是虚拟地址的划分在本质就是页表的划分,资源共享就是虚拟地址的共享在本质就是页表项(即虚拟页到物理页框的映射)共享。进程的vm_area_struct和mm_struct是资源的统计和管理。

三、线程周边

3.1 优点

  1. 创建一个线程的代价比进程要小很多,因为它复用了程序地址空间和页表
  2. 在等待慢速I/O操作结束时,程序可执行其他的计算任务
  3. 计算密集型应用(核心是CPU运算,很少IO),为了能在多处理器系统上运行,将计算分解到多个线程中实现
  4. I/O密集型应用(核心是等待IO操作,很少计算),为了提高性能,将I/O操作重叠,线程可以同时等待不同的I/O操作
  5. 与进程间切换相比,线程之间的切换的代价要很多(这里线程间切换是指在一个进程中)明面上是虚拟地址空间和页表不用修改,实际上会让缓存机制失效比如:快表和cache(用于缓存用户层数据)

3.2 异常

当线程出除0或野指针异常等其实就相当于是进程出现了异常,会导致该进程的所有线程都终止。

3.3 共享和私占

进程的大部分资源都是共享的像:文件描述符,信号处理方式,当前工作目录等。私占的最重要的就是线程拥有上下文数据------可被独立调度和拥有独立的栈结构------动态概念,其他的就是线程ID,调度优先级等。

四、线程控制

4.1 创建线程

创建线程是使用 POSIX 线程库里的函数,不对呀!为什么是库函数而不是系统调用。这是因为Linux是没有线程这一说他是用轻量级进程模拟实现线程,它没有传统意义上的线程!用户不想知道轻量级进程他只知道线程所以就有了该库,它被称为原生线程库(在底层实现使用了关于轻量级进程的系统调用)。

创建线程是用pthread_create():

参数:

  1. thread:存储创建线程id,创建失败它会被设置为未定义,pthread_t是unsigned long int只不过typedef了一下
  2. attr:设置线程属性,不想设置时设为nullptr(默认属性)即可
  3. start_routine:线程启动后执行的函数指针,该函数的返回值可以是任意类型包括类
  4. arg:传递给start_routine的参数

返回值:

  1. 成功返回0
  2. 失败返回非0错误码
cpp 复制代码
void* routine(void* arg)
{
    std::string str = static_cast<const char*>(arg);
    std::cout << "我是一个新线程,我的名字是:" << str << std::endl;
    sleep(1);

    return nullptr;
}

int main()
{
    pthread_t tid;
    int n = pthread_create(&tid, nullptr, routine, (void*)"pthread_1");
    if(n != 0)
        std::cout << strerror(n) << std::endl;

    int cnt = 3;
    while(cnt--)
    {
        std::cout << "我是main线程" << std::endl;
        sleep(1);
    }

    return 0;
}

通过ps -aL可以查看系统线程的命令,准确说是查看轻量级进程

从上图可以看到一个进程里的不同线程它们的PID是一样的,LWP表示轻量级进程的编号。CPU在调度时用LWP,它通过检查PID是否一样来判断是否复用资源。通过pthread_self()的返回值可以查看对应线程的对应的编号:

它怎么与LWP不一样?实际上就应该不一样!因为LWP是轻量级进程的id,而用户不认轻量级进程他只认线程!

4.2 线程等待

线程等待是用pthread_join():

等待线程并回收它的资源,防止出现僵尸线程,是同步线程的重要手段,只能阻塞式等待。

参数:

  1. thread:线程id
  2. retval:接受start_routine的返回值,不想接受可以设为nullptr

返回值:

  1. 成功时返回0
  2. 失败是返回非0错误码
cpp 复制代码
void* routine(void* arg)
{
    std::string str = static_cast<const char*>(arg);
    std::cout << "我是一个新线程,我的名字是:" << str << ",我的编号是:" << pthread_self() <<std::endl;
    sleep(1);

    return (void*)123;
}

int main()
{
    pthread_t tid;
    int n = pthread_create(&tid, nullptr, routine, (void*)"pthread_1");
    if(n != 0)
        std::cout << strerror(n) << std::endl;

    void* ret = nullptr;
    int m = pthread_join(tid, &ret);
    if(m != 0)
        std::cout << strerror(m) << std::endl;
    else
        std::cout << "routine的返回值是:" << (long long)ret << std::endl;

    return 0;
}

4.3 线程终止

线程终止是用pthread_exit(),类似于exit(),但它绝对不能随便使用否则进程就终止了:

参数:

  1. 指向线程退出时返回的数据,该值会被传递给等待当前线程的pthread_join()。如果不需要返回值设为NULL
cpp 复制代码
void* routine(void* arg)
{
    std::string str = static_cast<const char*>(arg);
    std::cout << "我是一个新线程,我的名字是:" << str << ",我的编号是:" << pthread_self() <<std::endl;
    sleep(1);

    pthread_exit((void*)100);
    std::cout << "新线程不会看到这里" <<std::endl;

    return (void*)123;
}

int main()
{
    pthread_t tid;
    int n = pthread_create(&tid, nullptr, routine, (void*)"pthread_1");
    if(n != 0)
        std::cout << strerror(n) << std::endl;

    void* ret = nullptr;
    int m = pthread_join(tid, &ret);
    if(m != 0)
        std::cout << strerror(m) << std::endl;
    else
        std::cout << "routine的返回值是:" << (long long)ret << std::endl;

    return 0;
}

还有一种是一个线程请求取消另一线程(线程也可以直接取消自己)pthread_cancel():

参数:

  1. thread:要取消线程的id

返回值:

  1. 成功时返回0
  2. 失败是返回非0错误码
cpp 复制代码
void* routine(void* arg)
{
    sleep(3);
    std::string str = static_cast<const char*>(arg);
    std::cout << "我是一个新线程,我的名字是:" << str << ",我的编号是:" << pthread_self() << std::endl;

    return (void*)123;
}

int main()
{
    pthread_t tid;
    int n = pthread_create(&tid, nullptr, routine, (void*)"pthread_1");
    if(n != 0)
        std::cout << strerror(n) << std::endl;

    int i = pthread_cancel(tid);
    if(i != 0)
        std::cout << strerror(i) << std::endl;
    else
        std::cout << "main线程已取消编号为" << tid << "的线程" << std::endl;

    void* ret = nullptr;
    int m = pthread_join(tid, &ret);
    if(m != 0)
        std::cout << strerror(m) << std::endl;
    else
        std::cout << "routine的返回值是:" << (long long)ret << std::endl;

    return 0;
}

被取消的线程的返回值是PTHREAD_CANCELED,它通常被定义为-1。还有一种终止是正常终止即return。

4.4 线程分离

线程分离是用pthread_detach():

分离态线程在终止时会自动释放资源,无需调用pthread_join()来回收否则会返回错误(Invalid argument),且无法获取其返回值。被分离的线程依然可以访问该进程的资源,线程不仅可以被分离还可以主动分离。

参数:

  1. thread:要分离线程的id

返回值:

  1. 成功时返回0
  2. 失败是返回非0错误码
cpp 复制代码
void* routine(void* arg)
{
    std::string str = static_cast<const char*>(arg);
    std::cout << "我是一个新线程,我的名字是:" << str << ",我的编号是:" << pthread_self() << std::endl;
    sleep(1);

    return (void*)123;
}

int main()
{
    pthread_t tid;
    int n = pthread_create(&tid, nullptr, routine, (void*)"pthread_1");

    int m = pthread_detach(tid);
    if(m != 0)
        std::cout << strerror(m) << std::endl;
    else
        std::cout << "main线程已把编号为" << tid << "的线程分离了" << std::endl;

    int i = pthread_join(tid, nullptr);
    if(i != 0)
        std::cout << "error: " << strerror(i) << std::endl;

    return 0;
}

4.5 demo

下面是多线程的简单实现:

cpp 复制代码
void* routine(void* arg)
{
    sleep(3);
    std::string str = static_cast<const char*>(arg);
    std::cout << "我是一个新线程,我的名字是:" << str << std::endl;

    delete[] (char*)arg;
    return nullptr;
}

int main()
{
    std::vector<pthread_t> tids;
    for(int i = 0; i < 10; i++)
    {
        pthread_t tid;
        char* name = new char[64];
        snprintf(name, 64, "pthread_%d", i);
        int n = pthread_create(&tid, nullptr, routine, name);
        if(n == 0)
        {
            tids.push_back(tid);
            std::cout << i << "号线程创建成功!" << std::endl;
        }
        else
            std::cout << i << "号error:" << strerror(n) << std::endl;
            
    }
    
    for(int i = 0; i < 10; i++)
    {
        int n = pthread_join(tids[i], nullptr);
        if(n == 0)
            std::cout << "等待线程成功!" << std::endl;
        else
            std::cout << i << "号error: " << strerror(n) << std::endl;
    }
    
    return 0;
}

五、进程地址空间分布

上面我提到线程id、线程返回值和参数和线程分离要怎么理解呢?

首先我们要明确在Linux中是没有真正意义上的线程它是用轻量级进程模拟实现,线程的概念是在线程库里维护(用户级库)。在该库里是有TCB的它用于描述像线程的状态、id、栈结构等,但是它并不描述像线程优先级、时间片,上下文等这些概念在轻量级进程的PCB里,可以把库里的TCB理解为就是记录了一下而线程实际的执行动作是轻量级进程实现的。线程库是动态库所以:

可以看到线程库与虚拟地址空间的共享区(mmap动态映射区)建立映射关系,线程库管理着所有线程。那线程在库中的资源结构是什么样呢?

struct pthread就是TCB。线程栈是每个线程执行时独立的栈空间,用于存储函数调用的上下文、局部变量等。线程局部存储是每个线程独有的存储区域,这块区域里的变量是全局变量不会和其他线程的同名变量相互干扰(在全局变量前加__thread)。

在上面我提到的线程id对应的其实就是TCB的起始地址,线程的返回值实际上是写入了TCB里的void* result 中然后 join 去TCB结构体中拿到返回值。用户写的函数和参数也会被放入TCB结构体里(这由pthread_create实现),用于调用系统调用时传递参数。对于线程分离就是把TCB里的joinable设置为0/1,为1即未分离状态为0即分离状态。那用户级线程是怎么和LWP产生关系的呢?

在使用pthread_create时会最先创建管理块即TCB(创建的规则遵循先管理在分配资源,资源在分配时要根据TCB里的某些数据来设置大小),然后会调用clone:

用于创建轻量级进程的系统调用,fn就是我们提供的start_routine,stack是create开辟的线程栈,一般是用malloc或mmap申请的空间并由库帮我们维护(它们没在栈区为什么还叫栈?这是因为它符合栈的特点),不过这种栈不能动态增长。当一个线程执行完后LWP会自动释放但它对应库里的资源并不会被释放(除非分离)需要 join。

相关推荐
我命由我123452 小时前
Git 暂存文件警告信息:warning: LF will be replaced by CRLF in XXX.java.
java·linux·笔记·git·后端·学习·java-ee
一只小透明啊啊啊啊3 小时前
进程、进程、内存、调度总结
linux
努力学习的小廉3 小时前
深入了解linux网络—— TCP网络通信(上)
linux·网络·tcp/ip
青草地溪水旁4 小时前
socketpair深度解析:Linux中的“对讲机“创建器
linux·服务器·socket编程
想唱rap4 小时前
Linux指令(1)
linux·运维·服务器·笔记·新浪微博
woshihonghonga4 小时前
Ubuntu20.04下的Pytorch2.7.1安装
linux·人工智能·ubuntu
字节高级特工4 小时前
网络协议分层与Socket编程详解
linux·服务器·开发语言·网络·c++·人工智能·php
minji...6 小时前
Linux 权限的概念及shell命令运行原理
linux·运维·服务器
欢鸽儿6 小时前
理解Vivado的IP综合策略:“Out-of-Context Module Runs
linux·ubuntu·fpga