【Linux学习】线程详解

目录

十八.多线程

[18.1 线程与进程](#18.1 线程与进程)

[18.2 内核视角看待创建线程与进程](#18.2 内核视角看待创建线程与进程)

[18.3 线程优缺点总结](#18.3 线程优缺点总结)

线程的优点:

线程的缺点:

线程的用途:

[18.4 线程与进程的联系](#18.4 线程与进程的联系)

十九.线程控制

[19.1 POSIX线程库](#19.1 POSIX线程库)

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

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

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

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

[19.6 线程ID及进程地址空间布局](#19.6 线程ID及进程地址空间布局)


十八.多线程

18.1 线程与进程

在Linux系统中,线程(Thread)和进程(Process)是两种不同的执行单元,它们之间有几个重要的区别:

  1. 资源共享:线程是属于同一进程的执行单元,它们共享同一进程的资源,如内存空间、文件描述符等。而进程是独立的执行单元,拥有独立的内存空间和资源,进程之间的通信通常需要通过特定的IPC(进程间通信)机制。

  2. 调度 :线程是由内核进行调度的最小执行单元,因此线程的切换开销通常比进程小。进程的调度由操作系统负责,而线程的调度可以在用户空间内完成,因此线程的切换速度通常更快。

  3. 创建和销毁开销:创建线程比创建进程要快,因为线程共享了父进程的地址空间和其他资源。销毁线程的开销也比销毁进程小。

  4. 独立性:进程是独立的执行环境,一个进程的崩溃通常不会影响其他进程;而线程是共享相同地址空间的执行单元,一个线程的崩溃可能会影响同一进程内的其他线程。

  5. 通信:进程之间的通信需要使用IPC机制,如管道、消息队列、共享内存等;而线程之间可以直接共享全局变量等数据,也可以使用线程间同步机制来进行通信和协调。

总的来说,线程是轻量级的执行单元,更适合用于并发编程和任务并行,可以更高效地利用系统资源;而进程是独立的执行环境,更适合用于资源隔离和单独执行任务。

18.2 内核视角看待创建线程与进程

之前表示进程所用的结构图:

一个进程的创建实际上伴随着其进程控制块(task_struct)、进程地址空间(mm_struct)以及页表的创建,虚拟地址和物理地址就是通过页表建立映射的

但如果我们在创建"进程"时,只创建task_struct,并要求创建出来的task_struct和父task_struct共享进程地址空间和页表,那么创建的结果就是下面这样的:

此时我们创建的实际上就是四个线程:

  • 其中每一个线程都是当前进程里面的一个执行流,也就是我们常说的"线程是进程内部的一个执行分支"。
  • 同时我们也可以看出,线程在进程内部运行,本质就是线程在进程地址空间内运行,也就是说曾经这个进程申请的所有资源,几乎都是被所有线程共享的。

如何理解之前的进程?

从内核角度来看:

  • 进程:它是承担分配系统资源的基本实体。
  • 线程:它是CPU调度的基本单位,承担进程资源的一部分的基本实体。

因此,我们从现在理解进程就不能说一个task_struct结构了,一个进程它包含了进程地址空间、文件相关的属性、各种信号、页表等

换言之,当我们创建进程时是创建一个task_struct、创建地址空间、维护页表,然后在物理内存当中开辟空间、构建映射,打开进程默认打开的相关文件、注册信号对应的处理方案等等。而我们之前接触到的进程都只有一个task_struct,也就是该进程内部只有一个执行流,即单执行流进程,反之,内部有多个执行流的进程叫做多执行流进程。

CPU如何看待task_struct?

CPU不管有多少条执行流,只看task_struct,你task_struct有1条执行流就是单执行流的task_struct,有多执行流,你就是多执行流的task_struct。如下图:

单执行流进程被调度:

多执行流进程被调度:

因此,CPU看到的虽说还是task_struct,但已经比传统的进程要更轻量化了。

Linux下并不存在真正的线程,而是用进程模拟的?

确实,在 Linux 系统中,并没有严格意义上的线程实体,而是使用进程模拟了线程的行为。在 Linux 内核中,线程被实现为轻量级进程(Lightweight Process,LWP),这些轻量级进程与父进程共享了相同的地址空间和其他资源,因此在用户空间看起来就像是在同一个进程中创建了多个线程一样。

Linux 内核提供了一些系统调用(如 clone()),允许创建这样的轻量级进程。这些轻量级进程可以与父进程共享资源,包括地址空间、文件描述符等。由于轻量级进程的实现方式与进程类似,因此内核中并不需要专门的线程管理模块,所有的线程操作都可以通过进程相关的系统调用来完成。

总的来说,Linux 中的线程实际上就是轻量级进程,是通过进程模拟实现的。尽管在用户空间中看起来像是在操作线程,但在内核层面实际上是在操作轻量级进程。因此,将 Linux 中的线程概念加上引号以示区分,是合适的。

18.3 线程优缺点总结

线程的优点:

  1. 低开销创建和切换: 创建一个新线程的开销比创建一个新进程小得多,线程之间的切换也相对较快。

  2. 资源消耗较少: 线程相比进程占用的资源要少很多,因为线程共享了相同进程的资源,如地址空间、文件等。

  3. 并发性能提升: 能够充分利用多处理器系统的并行性,提高程序的并发性能。

  4. IO操作重叠: 在IO密集型应用中,线程可以同时等待不同的IO操作,从而提高了IO操作的效率。

  5. 任务并行处理: 能够将任务分解成多个线程来同时执行,提高了计算密集型应用的执行效率。

线程的缺点:

  1. 性能损失: 当计算密集型线程的数量超过可用处理器的数量时,可能会造成性能损失,因为额外的同步和调度开销会增加。

  2. 健壮性降低: 编写多线程程序需要更加全面和深入的考虑,线程之间的同步和数据共享容易引发健壮性问题。

  3. 缺乏访问控制: 线程是进程内的执行分支,因此在多线程程序中,访问控制变得更加困难,可能会影响进程内部的资源访问。

  4. 编程难度提高: 编写和调试多线程程序比单线程程序更加困难,因为需要考虑线程同步、数据共享等问题。

线程的用途:

  1. 提高CPU密集型程序的执行效率: 合理利用多线程技术,将任务分解成多个线程来并行执行,提高计算密集型应用的执行效率。

  2. 提高IO密集型程序的用户体验: 在IO密集型应用中,使用多线程可以同时进行多个IO操作,提高了程序的响应速度和用户体验。

总的来说,线程是一种强大的并发编程工具,能够提高程序的并发性能和响应速度。但是在使用线程时,需要注意合理设计线程数量和任务分配,避免因过多线程导致的性能损失和健壮性问题。

18.4 线程与进程的联系

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

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

  • 线程ID
  • 一组寄存器(存储自己的上下文信息)
  • 栈(每个线程都有临时数据,都需要压栈出栈,各自独立)
  • errno
  • 信号屏蔽字
  • 调度优先级

多线程共享

共享同一地址空间,因此代码段(Text Segment)、数据段(Data Segment)都是共享的:

  • 如果定义一个函数,在各线程中都可以调用;
  • 如果定义一个全局变量,在各线程中都可以访问到;

除此之外,各线程还共享以下进程资源和环境:

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

十九.线程控制

19.1 POSIX线程库

应用层的原生线程库:

  • pthread 线程库是应用层的原生线程库,意味着它并非由操作系统内核直接提供,而是由第三方实现并被大部分操作系统默认包含。

函数系列:

  • 与线程相关的函数构成了一个完整的系列,大部分函数的名称都以 "pthread_" 打头,例如 pthread_create、pthread_join、pthread_mutex_init 等。

使用方法:

  • 要使用 pthread 线程库,需要包含头文件 <pthread.h>
  • 在链接时,需要使用编译器的**-lpthread**选项来链接 pthread 库。

错误检查:

  • 传统的一些函数在出错时会设置全局变量 errno,并返回 -1 来表示错误,例如 read、write 等。
  • pthread 函数在出错时不会设置全局变量 errno,而是通过返回值来表示错误。通常,成功返回 0,失败返回非零值。
  • pthread 同样提供了线程内的 errno 变量,以支持其他使用 errno 的代码,但是建议优先使用返回值来判断错误,因为读取返回值的开销更小。

19.2 线程创建

创建线程的函数叫做pthread_create,函数原型如下:

cpp 复制代码
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
  • 参数说明:

    • thread:获取创建成功的线程ID,是一个输出型参数。
    • attr:用户设置线程的属性,传入 NULL 表示默认属性。
    • start_routine:表示线程的入口函数,即线程启动后要执行的函数。
    • arg:传给线程入口函数的参数。
  • 返回值说明:

    • 线程创建成功返回 0,失败返回相应的错误码。

让主线程创建一个新线程

  • 当一个程序启动时,操作系统会创建一个进程,同时也会创建一个线程,这个线程称为主线程。

  • 主线程通常是产生其他子线程的线程,在启动其他线程后,主线程可以继续执行其他操作,也可以等待子线程执行完毕后再继续执行。

  • 主线程通常负责完成一些必要的执行动作,比如初始化操作、资源的释放等。

下面是一个示例代码,展示了如何在主线程中创建一个新线程:

cpp 复制代码
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
void* thread_run(void* args)                                                                                                                             
{      
    const char* id = (const char*)args;      
    while(1){      
        printf("I am %s thread, %d\n", id, getpid());      
        sleep(1);      
    }      
}      
      
int main()      
{      
    pthread_t tid; 
    pthread_create(&tid, NULL, thread_run, (void*)"thread 1");      
    while(1){      
        printf("I am mian thread, %d\n",getpid());      
        sleep(1);      
    }      
    return 0;      
}

注意:你需要在编译时添加 -pthread 选项来链接 pthread 库

运行结果:

此时使用ps axj的命令查看进程信息: 虽然此时该进程中有两个线程,但是我们看到的进程只有一个,因为这两个线程都是属于同一个进程的。

使用ps -aL命令,可以显示当前的轻量级进程。

  • 不带-L,看到是就一个个的进程
  • 带-L,看到的是每个进程内的多个轻量级进程

其中,LWP(light weight process)就是轻量级进程的ID,可以看到显示的两个轻量级进程的PID是相同的,因为它们属于同一个进程。

注意: 在Linux中,应用层的线程与内核的LWP是一一对应的,实际上操作系统调度的时候采用的是LWP,而并非PID,只不过我们之前接触到的都是单线程进程,其PID和LWP是相等的,所以对于单线程进程来说,调度时采用PID和LWP是一样的。

获取线程ID

在提取线程 ID 的过程中,常见的两种方式如下:

  • 创建线程时通过输出型参数获得。
  • 通过调用 pthread_self 函数获得。
cpp 复制代码
pthread_t pthread_self(void);

调用pthread_self函数即可获得当前线程的ID,类似于调用getpid函数获取当前进程的ID。

例如下面的代码,我们通过主线程创建一个新线程,主线程不断的打印新线程的ID,新线程去执行回调函数,打印出自己的ID:

cpp 复制代码
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

void* thread_run(void* args) {
    while(1) { //新线程打印自己的ID
        printf("我是新线程[%s],我线程ID是:%lu\n", (const char*)args, pthread_self());
        sleep(1);
    }
}

int main() {
    pthread_t tid;
    pthread_create(&tid, NULL, thread_run, (void*)"new thread");
    while(1) { //主线程先是创建新线程并打印新线程的ID
        printf("我是主线程,我创建的线程ID是:%lu, 我的线程ID是:%lu\n", tid, pthread_self());
        sleep(1);
    }
}

运行代码,可以看到这两种方式获取到的线程的ID是一样的。

19.3 线程等待

线程等待是指一个线程等待另一个线程执行完毕后再继续执行的过程。在 POSIX 线程中,常用的线程等待函数是 pthread_join。一个线程被创建出来,就如同进程一般,也是需要被等待的。如果主线程不对新线程进行等待,那么这个新线程的资源也是不会被回收的。线程需要被等待,如果不等待会产生类似于"僵尸进程"的问题,也就是内存泄漏。

线程等待的函数: pthread_join()

cpp 复制代码
int pthread_join(pthread_t thread, void **retval);

参数说明:

  • thread:被等待线程的ID
  • retval:它是一个输出型参数,用来获取新线程退出的时候,函数的返回值;新线程函数的返回值是void*,所以要获取一级指针的值,就需要二级指针,也就是void**;

返回值

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

使用说明:

  • 调用该函数的线程将挂起等待,直到被指定的线程结束。
  • 被等待的线程以不同的方式终止,根据终止方式,retval 所指向的单元里存放的值也会不同:
    • 如果被等待的线程通过 return 返回,retval 所指向的单元里存放的是被等待线程函数的返回值。
    • 如果被等待的线程被其他线程调用 pthread_cancel 异常终止,retval 所指向的单元里存放的是常数 PTHREAD_CANCELED
    • 如果被等待的线程是自己调用 pthread_exit 终止的,retval 所指向的单元存放的是传给 pthread_exit 的参数。
    • 如果对被等待线程的终止状态不感兴趣,可以传入 NULLretval 参数。

为什么线程异常终止会导致整个进程崩溃?

  1. 进程异常处理: 当一个进程中的某个子进程异常终止时,父进程可以通过 wait()waitpid() 函数获取到子进程的退出状态,包括退出码、终止信号等信息。这是因为子进程和父进程是相互独立的,一个子进程的异常终止不会影响到父进程的执行。

  2. 线程异常处理: 在一个进程中,所有线程共享同一地址空间和资源,它们是相互依赖的。如果一个线程异常终止,整个进程都会受到影响,因为线程之间是共享资源的。当一个线程异常终止时,整个进程的执行状态就会变得不确定,甚至可能导致进程崩溃。因此,pthread_join() 函数只能获取到线程正常退出时的退出码,而不能获取到线程异常退出的信息。

  • 一个线程的错误可能会影响到其他线程以及整个进程的执行状态。如果一个线程访问了无效的内存地址,或者发生了其他类似的错误,可能会导致整个进程崩溃。
  • 因此,当一个线程异常终止时,整个进程的执行状态就变得不确定。此时,父线程(或者主线程)可能无法正常地调用 pthread_join() 函数来等待子线程的结束,因为整个进程可能已经处于异常状态,无法继续执行。

模拟野指针问题

cpp 复制代码
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

void* thread_run(void* args) {
    int num = *(int*)args;
    while(1) {
        printf("I am new thread [%d], my thread ID is: %lu\n", num, pthread_self());
        sleep(1);
        // 模拟异常情况
        if(num == 3) {
            printf("Thread number: %d quit due to an error.\n", num);
            int* p = NULL;
            *p = 100;
        }
    }
}

int main() {
    pthread_t tid[5];
    for(int i = 0; i < 5; i++) {
        pthread_create(tid + i, NULL, thread_run, (void*)&i);
    }

    // 等待线程结束
    for(int i = 0; i < 5; i++) {
        void* status = NULL;
        pthread_join(tid[i], &status);
        printf("Thread [%d] exit with status: %d\n", i, (int)status);
    }

    return 0;
}

19.4 线程终止

线程的终止是线程生命周期的一个重要部分。在多线程编程中,正确地管理线程的终止是确保程序稳定性和可靠性的关键。本节将介绍线程终止的几种常见方式以及如何安全地终止线程。

1. 正常退出

线程可以通过以下方式正常退出:

  • 返回语句: 线程函数可以通过简单地返回来正常退出。在这种情况下,线程函数中的 return 语句将会返回一个值,并且该值将会成为线程的退出状态,可以由其他线程通过 pthread_join 获取到。
cpp 复制代码
void* thread_function(void* arg) {
    // 线程执行的代码
    return (void*)42; // 返回退出状态
}
  • 调用 pthread_exit 函数: 可以在线程函数内部显式地调用 pthread_exit 函数来退出线程。与返回语句相比,使用 pthread_exit 可以更明确地指定线程的退出状态。
cpp 复制代码
void pthread_exit(void *retval);
cpp 复制代码
void* thread_function(void* arg) {
    // 线程执行的代码
    pthread_exit((void*)42); // 退出线程并指定退出状态
}

2. 异常退出

线程可能会因为各种异常情况而提前退出,这时候需要考虑如何优雅地处理线程的终止。

  • 取消线程: 可以使用 pthread_cancel 函数取消线程的执行。被取消的线程会在接收到取消请求后立即退出,但是需要确保线程的资源得到正确释放,避免资源泄漏。
cpp 复制代码
pthread_cancel(thread_id); // 取消线程的执行

异常处理: 在线程函数内部进行异常处理,确保线程在出现异常时能够安全地退出,释放资源。

19.5 线程分离

线程分离是多线程编程中的重要概念,它涉及到线程的生命周期管理和资源释放。本节将介绍线程分离的概念、作用以及如何在编程中正确地使用线程分离。

1. 线程分离的概念

线程分离是指将一个线程从其创建者(通常是主线程)中分离出来,使得该线程在终止时能够自行释放资源,而无需其他线程显式地调用 pthread_join 函数等待其结束。分离线程后,不需要调用 pthread_join 函数来等待线程的结束,线程结束时系统会自动释放其资源。

2. 线程分离的作用

  • 资源释放: 分离线程可以确保线程在结束时自动释放其占用的资源,避免资源泄漏。
  • 避免僵尸线程: 分离线程可以避免产生僵尸线程,提高程序的稳定性和可靠性。
  • 简化代码: 分离线程可以简化代码逻辑,不需要显式地等待线程结束。

3. 使用 pthread_detach 函数分离线程

可以使用 pthread_detach 函数将线程分离,使得该线程在结束时自动释放资源。

cpp 复制代码
pthread_detach(thread_id); // 将线程分离

4. 示例代码

下面是一个示例代码,演示了如何创建线程并将其分离:

cpp 复制代码
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

void* thread_function(void* arg) {
    // 线程执行的代码
    printf("Thread is running...\n");
    sleep(3); // 模拟线程执行一段时间
    printf("Thread finished.\n");
    pthread_exit(NULL); // 结束线程
}

int main() {
    pthread_t tid;
    pthread_create(&tid, NULL, thread_function, NULL); // 创建线程
    pthread_detach(tid); // 分离线程
    printf("Main thread finished.\n");
    return 0;
}

在这个示例中,主线程创建了一个新线程,并立即将其分离。因此,主线程不需要显式地等待新线程结束,而是可以立即继续执行后续代码。当新线程执行完毕后,系统会自动回收其资源。

5. 注意事项

  • 分离已结束的线程: 要确保在调用 pthread_detach 函数之前,线程尚未结束。如果尝试分离已经结束的线程,可能会导致不确定的行为。
  • 资源释放: 分离线程仅仅负责释放线程占用的资源,而不负责资源的释放。因此,在线程中分配的内存等资源需要在线程结束时手动释放,以避免资源泄漏。

线程分离是多线程编程中的一项重要技术,能够简化代码逻辑并提高程序的稳定性和可维护性。正确地使用线程分离可以有效地管理线程的生命周期,避免资源泄漏和僵尸线程的产生。

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

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

pthread_t到底是什么类型呢?

  • 在Linux系统中,pthread_t 是一个不透明的数据类型,用于表示线程的标识符。它实际上是一个指向线程控制块(TCB)的指针,TCB 包含了线程的各种属性和状态信息,如线程ID、栈信息、调度优先级等。
  • 当调用 pthread_create 函数创建新线程时,它会返回一个 pthread_t 类型的变量,这个变量实际上是一个对应于新创建线程的唯一标识符。该标识符由 NPTL 线程库使用,用于在用户空间管理线程的状态和属性,并与内核级的 LWP(轻量级进程)关联起来。
  • 线程库通过 pthread_t 变量来管理每个线程的状态和属性。线程库会根据 pthread_t 变量找到相应的线程控制块,然后根据需要对线程进行操作.pthread_t 类型取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。
  • 总的来说,pthread_t 是线程库提供的一种抽象类型,用于标识和管理线程,由线程库内部使用,开发者只能通过 pthread_create 等接口来创建和操作线程。

通过ldd命令可以看到,我们采用的线程库实际上是一个动态库。

  • 在Linux系统中,动态库加载后需要将动态库本身的全部信息映射到主线程的堆、栈之间的共享区域。动态库除了包含代码之外,还需要维护线程创建的数据结构,这些数据结构通常存储在共享区域中,供所有线程访问和修改。
  • 每个线程运行时都需要有自己的临时数据,因此需要有私有的栈结构。在地址空间中,通常只有一个栈,这个栈是用来给主线程使用的,而其他线程会使用动态库中维护的栈结构。这样就可以确保每个线程都有自己的私有栈,不会与其他线程共享栈空间。
  • 动态库本身还负责线程的组织和管理工作,每个线程在地址空间中都会有一个 struct pthread(线程结构体)以及线程局部存储和线程栈。这些信息由动态库来维护,实现了"先描述、再组织"的方式。每个新线程在共享区域都有一块描述其信息的区域,通过这个区域的起始地址可以获取到线程的各种信息。
  • 在内核中,LWP(轻量级进程)和 struct pthread(线程结构体)是一一对应的关系。在用户层,如果存在多个线程结构体,为了和内核的 LWP 一一对应,这些线程结构体中一定会包含对应的 LWP。这样就可以确保在用户空间中的线程结构体和内核中的 LWP 是一一对应的关系,方便线程的管理和调度。
相关推荐
黑牛先生1 分钟前
【Linux】进程-PCB
linux·运维·服务器
Karoku0667 分钟前
【企业级分布式系统】ELK优化
运维·服务器·数据库·elk·elasticsearch
友友马20 分钟前
『 Linux 』网络层 - IP协议(一)
linux·网络·tcp/ip
新知图书31 分钟前
Rust编程与项目实战-模块std::thread(之一)
开发语言·后端·rust
盛夏绽放1 小时前
Node.js 和 Socket.IO 实现实时通信
前端·后端·websocket·node.js
Ares-Wang1 小时前
Asp.net Core Hosted Service(托管服务) Timer (定时任务)
后端·asp.net
猿java1 小时前
Linux Shell和Shell脚本详解!
java·linux·shell
安迁岚1 小时前
【SQL Server】华中农业大学空间数据库实验报告 实验三 数据操作
运维·服务器·数据库·sql·mysql
vmlogin虚拟多登浏览器2 小时前
虚拟浏览器可以应对哪些浏览器安全威胁?
服务器·网络·安全·跨境电商·防关联
A.A呐2 小时前
【Linux第一章】Linux介绍与指令
linux