Linux多线程

文章目录:

  • Linux线程概念
    • 什么是线程?
    • 二级页表
    • 线程的优点
    • 线程的缺点
    • 线程异常
    • 线程用途
  • Linux进程 vs 线程
    • 进程和线程
    • 进程的多个线程共享
  • Linux线程控制
    • POSIX线程库
    • 线程创建
    • 线程终止
    • 线程等待
    • 分离线程
    • 线程ID及进程地址空间布局

Linux线程概念

什么是线程?

  • 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是 “一个进程内部的控制序列”。
  • 一个进程至少有一个主线程,但也可以创建额外的线程来执行并发任务。
  • 线程与进程共享同一个地址空间,这意味着每个线程都可以访问进程中的所有资源。
  • Linux 系统中,线程与进程共享同一个 PCB(进程控制块),因此线程的创建和切换比进程更加轻量化。
  • 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。

如何理解之前的进程?

下图所示,黑色框所包含的整体称为进程:

Linux多线程_第1张图片
通过上图,我们可以将进程视为承担分配系统资源的基本实体,它由多个组成部分构成,包括 task_struct 、进程地址空间、文件、信号等。创建进程时,需要创建 task_struct 并分配资源,同时还需要创建进程地址空间、维护页表等。一个进程可以是单执行流的,也可以是多执行流的,即内部有多个执行流的进程。

在 Linux 中,从 CPU 的角度来看,能否识别当前调度的 task_struct 是进程还是线程?

在 Linux 中,CPU 并不能识别当前调度的 task_struct 是进程还是线程,因为线程和进程在内核中是没有本质区别的,它们都由 task_struct 表示。站在 CPU 的角度,它只能看到一个个的 task_struct ,无法区分它们是进程还是线程。实际上, Linux 中的线程是一种特殊的进程,它们共享进程的同一个进程地址空间和其它资源,只有线程 ID 和一些特定的线程相关的属性是不相同的。

Linux 下的线程是通过复用进程控制块而实现的

在一个操作系统中通常存在着大量的进程,一个线程是一个进程的内部执行流,线程的数量通常比进程的数量多(线程:进程 = n:1)。线程的执行粒度比进程更细,因为线程可以共享进程的地址空间和其它资源,线程之间切换的成本相对进程更低。

对于操作系统来说,支持线程需要对进程进行管理、包括创建线程、终止线程、调度线程、切换线程、分配和释放资源等操作。不同的操作系统对线程的支持程度不同,有些操作系统提供真正的线程支持(如 Windows 操作系统),需要单独设计和实现线程管理模块,非常复杂。有些操作系统则将线程看作轻量级进程,与进程共享同一个控制块(如 Linux 操作系统),通过复用进程控制块来实现线程的管理,因此相对于其它真正线程的操作系统,其实现逻辑相较简单。


虽然 Linux 中没有真正意义上的线程,但是 Linux 提供了一些创建轻量级进程的接口。其中创建进程,共享空间的最典型的函数就是 vfork 。

vofk 函数的主要作用是创建一个新的子进程,该子进程与父进程共享地址空间,vfork 函数定义如下:

#include 
#include 

pid_t vfork(void);q

函数调用成功之后,给父进程返回子进程的 PID,给子进程返回 0 。

示例:下列代码使用 vfork 函数创建了一个子进程,子进程中将全局变量的值进行修改并退出,父进程休眠 2 秒之后读取全局变量的值:


#include 
#include 
#include 
#include 
using namespace std;

int g_val = 5;

int main()
{
    pid_t id = vfork();
    if (id == 0)
    {
        g_val = 7;
        cout << "I am a child process , pid : " << getpid() << "   g_val = " << g_val << endl;
        exit(0);
    }
    sleep(2);
    cout << "I am a parent process , pid : " << getpid() << "   g_val = " << g_val << endl;
    return 0;
}

运行测试如下:由于子进程和父进程共享同一块地址空间,因此子进程对 g_val 的修改父进程也会读取到。

Linux多线程_第2张图片

虽然 vfork 函数可以创建轻量级进程,但它并不是线程的实现方式。其中,最常用的系统调用是 pthread_create ,它允许程序在同一个进程中创建多个执行流。此外,Linux 还提供了其它有关于线程的系统调用,它们包含在 pthread 库中。

在 Linux 中,并没有真正意义上的线程概念,但是为了便于编写程序,Linux 提供了原生线程库 pthread ,它在用户层模拟实现了一套线程相关的接口。用户可以使用 pthread 库来创建、销毁和管理线程,这些线程在用户层被视为独立的执行流,但实际上它们都是内核调度的轻量级进程。

因此,学习 Linux 下的线程实际上是学习如何使用 pthread 库来模拟实现线程相关的操作,而不是直接操作内核的接口。

线程是 CPU 调度的基本单位。

二级页表

二级页表是一种实现分页机制的方法,它可以解决大型进程的内存管理问题。在大型进程中,如果使用单级页表,那么它将需要非常大的页表,这可能会导致内存浪费和性能下降。

在 Linux 中,32 位平台下用的是二级页表,而 64 位平台下用的是多级页表。

示例:在 32 位平台下一共有 232 个地址,如果页表只是一级页表,那么这张表就需要建立 232 个虚拟地址和物理地址之间的映射关系。每个页表项包含一个虚拟地址和一个物理地址的映射,以及一些标志位,如可读写位、执行位等。

Linux多线程_第3张图片

(32位平台下)每个页表项中存储一个物理地址一个虚拟地址需要 8 个字节,再加上一些标志位和其它信息,一个页表项大概需要占用 10 个字节。若有 232 个表项,也就意味着存储这张表需要使用 232 *10 个字节,即 40 GB。而在 32 位平台下内存只有 4 GB,这样一张表根本无法存储下来。

因此,如果一张页表的大写超过 4GB ,我们就无法将整个页表放入内存中了。因此,需要使用多级页表来减小页表的大小,并提高页表查询效率。

为什么需要二级页表?

  • 页表必须连续存放,因此当页表很大时,需要占用很多个连续的页框。
  • 没有必要让整个页表常驻内存,因为进程在一段时间内可能只需要访问某几个特定的页面。

对于 32 位平台,其页表映射过程如下:

  • 选择虚拟地址的前 10 个比特位作为页目录项的索引,从页目录中获取对应第二级页表的地址。
  • 再选择接下来的 10 个比特位作为页表项的索引,从页表中获取物理内存中对应页框的起始地址。
  • 将虚拟地址的后 12 个比特位作为偏移量,从对应页框的起始地址处向后进行偏移,得到物理地址。

Linux多线程_第4张图片

物理内存被划分为一个个大小为 4KB 的页框,而磁盘上的程序也是被划分为一个个大小为 4KB 的页帧。在内存和磁盘进行数据交换时,以 4KB 大小为单位进行加载和保存的,这也是操作系统的经典分页技术。

Linux多线程_第5张图片

所谓的二级页表。就是在这种分页机制下,虚拟地址被分为两部分,高 10 位表示页目录项的索引,中间 10 位表示页表项的所有,低 12 位表示页内偏移量。

若每一个页表项的大小为 10 个字节,页目录和页表项都是 210 个,因此一个表的大小就是 210*10 个字节,也就是 10 KB。页目录有 210 个表项也就意味着有 210 个页表,即一级页表有一张,二级页表有 210 张,总计大概 10 MB,内存消耗较低。

二级页表的有点是可以处理更大的虚拟地址空间,而且由于页面的层级结构,可以减少页表的大小,从而节省内存。在实际操作系统中,页表的大小和结构可能会根据具体的需求和硬件特性进行调整。

线程的优点

  • 创建一个新线程的代价要比创建一个新进程小得多。
  • 与进程之间得切换相比,线程之间得切换需要操作系统做的工作要少很多,线程切换代价小。
  • 线程占用得资源要比进程少很多。
  • 能够充分利用多处理器得可并行数量。
  • I/O 操作重叠,在等待慢速 I/O 操作结束的同时,线程可执行其它的计算任务,从而提高系统的吞吐量。对于 I/O 密集型应用,为了提高性能,可以将 I/O 操作重叠,使得线程可以同时等待不同的 I/O 操作。
  • 计算密集型应用,为了能够在多处理器系统上允许,可以将计算分解到多个线程中实现,从而充分利用多处理器的可并行数量,提高性能。

线程的缺点

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

线程异常

  • 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
  • 线程是进程得执行分支,线程出现异常,就相当于进程出现异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。

线程用途

  1. 合理使用多线程,能提高 CPU 密集型程序的执行效率。
  2. 合理使用多线程,能提高 IO 密集型程序的用户体验(如一边写代码一边下载软件,就是多线程运行的一种表现)。

Linux进程 vs 线程

进程和线程

进程是资源分配的基本单位,可以独立运行,与其它进程隔离;线程是调度的基本单位,可以充分利用多处理器的可并行数量,提高程序的执行效率。线程共享进程的地址空间和其它资源,但也拥有自己的一部分数据:线程ID、一组寄存器、栈、errno、信号屏蔽字、调度优先级,可以独立执行,但是需要遵守同步和互斥等机制,以确保数据的正确性和一致性。

进程的多个线程共享

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

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

进程和线程的关系如下图:Linux多线程_第6张图片

Linux线程控制

POSIX线程库

POSIX线程库是一套用于多线程编程的标准 API,通常被称为 pthread 库。它包含了一系列的函数,用于创建、管理和同步线程。

  • 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以 “pthread_” 打头的。
  • 要使用这些函数库,需要引入头文件
  • 链接这些线程函数库时要使用编译器命令的 “-lpthread” 选项。

线程创建

pthread_create 是一个 POSIX 线程库函数,用于创建一个新的线程,它的定义如下:

#include 

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

// Compile and link with -pthread

参数:

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

pthread_create 函数返回 0 表示成功,否则表示失败。在成功创建新线程后,线程会立即执行 srart_routine 函数中的代码。

错误检查:

  • 传统的一些函数是,成功返回 0 ,失败返回 -1,并且对全局变量 errno 赋值以指示错误。
  • pthreads 函数出错时不会设置全部变量 errno(而大部分其它 POSIX 函数会这样)。而是将错误代码通过返回值返回。
  • pthreads 同样也提供了线程内的 errno 变量,以支持其它使用 errno 的代码。对于 pthreads 函数的错误,建议通过返回值判定,因为读取返回值要比读取线程内的 errno 变量的开销更小。

示例,使用 POSIX 线程库的 pthread_create 函数来创建一个新的线程。当程序运行之后新线程和主线程就各自执行自己的方法:

#include 
#include 
#include 

using namespace std;

void *startRoutine(void *args)
{
    string name = (char *)args;
    while (true)
    {
        cout << name << " : " << ::getpid() << endl;
        sleep(1);
    }
}

int main()
{
    pthread_t tid;
    int count = 0;
    pthread_create(&tid, nullptr, startRoutine, (void *)"thread 1");

    while (true)
    {
        cout << "我是主线程..." << ::getpid() << endl;
        sleep(2);
    }
    return 0;
}

运行程序:

Linux多线程_第7张图片

当使用 ps axj 查看进程的状态信息时,虽然该进程中有两个线程,但是我们只能看到一个进程。这是因为 Linux 操作系统将线程视为进程(或轻量级进程)的一部分,因此它们共享相同的进程标识符(PID)和其它属性。

在这里插入图片描述

因此,当使用 ps 命令查看进程信息时,它只会显示进程的主线程而不会列出其它线程。如果要查看一个进程中的所有线程信息,可以使用 ps -aL 命令显示进程的所有线程:

Linux多线程_第8张图片

上面的 LWP(Light Weight Process)是指轻量级进程,它是线程在内核中对应的实体。可以看出两个线程的 PID 是相同的,证明了它们属于同一个进程。

线程终止

如果只需要终止某个线程而不终止整个进程,可以有三种方法:

1️⃣ 从线程函数 return 。这种方法只适用于线程函数本身的终止,对于主线程不适用,从 main 函数 return 相当于调用 exit,会终止整个进程。

2️⃣ 线程可以调用 pthread_exit 终止自己。

3️⃣ 一个线程可以调用 pthread_cancel 终止同一个进程中的另一个线程,用来取消指定线程的执行。

pthread_exit函数

pthread_exit 函数用于线程终止,定义如下:

#include 

void pthread_exit(void *retval); // retval不要指向一个局部变量
// 无返回值,跟线程一样,线程结束的时候无法返回她的的调用者(自身)

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

示例:下列代码演示了使用 pthread 库创建线程,并通过 pthread_exit 函数终止线程,并返回一个值。

#include 
#include 
using namespace std;

// pthread_exit演示
void *startRoutine(void *args)
{
    cout << "thread executing" << endl;
    // ...
    // 终止当前进程并返回值
    pthread_exit(reinterpret_cast<void *>(111));
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, startRoutine, nullptr);

    // 等待线程执行完毕
    void *result=nullptr;
    pthread_join(tid, &result); // void **__thread_return 是一个输出型参数

    // 打印线程的返回值
    cout << "thread return value : " << reinterpret_cast<intptr_t>(result) << endl;
    return 0;
}

运行程序:

在这里插入图片描述

pthread_cancel 函数

pthread_cancel 函数用于取消一个正在运行的线程。它接受一个线程标识符作为参数,并向执行的线程发送一个取消请求。被取消的线程会在适当的时候终止线程。定义如下:

#include 

int pthread_cancel(pthread_t thread); // thread:线程ID
// 返回值:成功返回0;失败返回错误码

示例:

#include 
#include 
using namespace std;

// 线程执行的逻辑
void *startRoutine(void *args)
{
    std::cout << "Thread executing" << std::endl;
    while (1)
    {
        // 模拟线程执行的任务
        sleep(1); // 暂停1s,否然while循环一直运行,线程取消后不能被响应
    }
}
int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, startRoutine, nullptr);

    // 等待一段时间后取消线程
    sleep(2);
    int result = pthread_cancel(tid);
    if (result != 0)
    {
        std::cerr << "Failed to cancel thread" << std::endl;
        return 1;
    }

    // 等待线程执行完毕
    void *threadResult;
    pthread_join(tid, &threadResult);
    if (threadResult == PTHREAD_CANCELED)
        std::cout << "Thread canceled" << std::endl;
    else
        std::cout << "Thread not canceled" << std::endl;
    return 0;
}

运行测试:

Linux多线程_第9张图片

线程等待

为什么需要进行线程等待?

即使一个线程退出了,它的资源可能还没有被完全释放,仍然在进程的地址空间内。因此,在创建新的线程时,不能简单的复用刚才退出线程的地址空间,需要使用新的内存空间来创建新的线程。因此,我们确保线程资源得到正确地管理和释放,所以要进行线程等待以回收资源。

使用 pthread_join 函数等待线程结束,函数定义如下:

#include 

int pthread_join(pthread_t thread, void **retval);

参数:

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

返回值: 成功则返回 0 ,失败返回错误码。

调用该函数的线程将挂起等待,直到 id 为 thread 的线程终止。thread 函数以不同的方法终止,通过 pthread_join 得到的终止状态是不同的,总结如下:

  • 如果 thread 线程通过 return 返回,value_ptr 所指向的单元里存放的是 thread 线程函数的返回值。
  • 如果 thread 线程被别的线程调用 pthread_cancal 异常终止,value_ptr 所指向的单元里存放的是常数 PTHREAD_CANCELED。
  • 如果 thread 线程是自己调用 pthread_exit 终止的,value_ptr 所指向的单元存放的是传给 pthread_exit 的参数。
  • 如果对 thread 线程的终止状态不感兴趣,可以传 NULL 给 value_ptr 参数。

Linux多线程_第10张图片

示例:下列代码创建 5 个线程。主线程创建完所有子线程后,等待每个线程结束并回收。

#include 
#include 
#include 
#include 
using namespace std;

void *startRoutine(void *args)
{
    char *s = (char *)args;
    cout << "I am " << s << " , pid : " << getpid() << " tid : " << pthread_self()<<endl;
    sleep(2);
}

int main()
{
    pthread_t tid[5];
    for (int i = 0; i < 5; i++)
    {
        char *buffer = (char *)malloc(128);
        sprintf(buffer, "thread %d", i);
        pthread_create(&tid[i], nullptr, startRoutine, buffer);
    }
    sleep(1);
    cout << "mian thread   , pid : " << getpid() << " tid : " << pthread_self()<<endl;

    for (int i = 0; i < 5; i++)
    {
        pthread_join(tid[i], nullptr);
        cout << " thread " << i << " -> " << tid[i] << endl;
    }
    return 0;
}

运行结果:

Linux多线程_第11张图片

分离线程

  1. 默认情况下,新创建的线程是 joinable 的,线程退出后,需要对其进行 pthread_join 操作,否则无法释放资源,从而造成系统资源泄漏。
  2. 如果不关心线程的返回值,join 是一种负担,这个时候,可以告诉系统,当线程退出时,自动释放线程资源。
int pthread_detach(pthread_t thread); // thread -> 被分离线程的ID
  1. 可以是线程组内其它线程对目标线程进行分离,也可以是线程自己分离。
pthread_detach(pthread_self());
  1. joinable 和分离是冲突的,一个线程不能即使 joinable 又是分离的。

示例:创建三个线程,每个线程执行 startRoutine 函数。主线程对创建的三个线程进行分离,每个线程执行两次后退出,主线程进入循环。

#include 
#include 
#include 
#include 
using namespace std;

// 线程分离演示
void *startRoutine(void *args)
{
    char *name = static_cast<char *>(args);
    int count = 1;
    while (count <= 2)
    {
        cout << name << " count = " << count << endl;
        count++;
        sleep(1);
    }
}

int main()
{
    pthread_t tid1, tid2, tid3;
    pthread_create(&tid1, nullptr, startRoutine, (void *)"thread 1");
    pthread_create(&tid2, nullptr, startRoutine, (void *)"thread 2");
    pthread_create(&tid3, nullptr, startRoutine, (void *)"thread 3");

    // 倾向于:让主线程分离其它线程,也可以在其它线程执行流内进行分离
    pthread_detach(tid1);
    pthread_detach(tid2);
    pthread_detach(tid3);

    while (true) {}
    return 0;
}

运行测试如下:创建出的三个线程执行结束之后自动被回收,不需要主线程对它们进行等待。

Linux多线程_第12张图片

说明:使用 pthread_detach 函数可以方便地将线程标记为可分离状态,从而避免手动调用 pthread_join 函数等待线程结束回收资源。在特定情况下可以简化代码逻辑和资源管理。

线程ID及进程地址空间布局

  • pthread_create 函数会产生一个线程 ID,存放在第一个参数指向的地址中。该线程 ID 和前面说的线程 ID(LWP) 不是一回事。
  • 之前所说的 LWP 是属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
  • pthread_create 函数的第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程 ID,属于 NPTL 线程库的范畴。线程库的后续操作,就是根据该线程 ID 来进行操作线程的。
  • 线程库 NPTL 提供了 pthread_self 函数,可以获得线程自身的 ID。

pthread_t 到底是什么类型呢?

Linux 提供了基于内核执行流的轻量级进程(LWP),它们是操作系统调度和管理的基本单位。也就意味着操作系统只需要对内核执行流 LWP 进行管理,而提供给用户使用的线程接口等其它数据,因由线程库自己管理。

/* Thread identifiers.  The structure of the attribute type is not
   exposed on purpose.  */
typedef unsigned long int pthread_t;

通过命令 ldd 查看线程库:

Linux多线程_第13张图片


在 Linux中,每个线程都有自己的私有栈空间,用于保存线程的局部变量和函数调用信息。主线程使用的栈是进程地址空间中的原生的栈,而其它线程使用的栈是在共享区中分配的。此外,每个线程都有一个 struct pthread 结构,用于存储线程的各种属性(线程ID,状态,优先级等信息)。线程还有自己的线程局部存储(Thread Local Storage,TSL),用于保存线程在切换时需要保存的上下文数据。

每个新线程在共享区中都有一个对应的内存块来描述它。通过找到线程内存块的起始地址,可以获取到线程的各种信息。

Linux多线程_第14张图片

你可能感兴趣的:(Linux,linux,运维,服务器)