深入探讨多线程编程:从0-1为您解释多线程(上)

发布时间:2023年12月25日

引言
多线程编程是现代软件开发中不可或缺的一部分。本文将深入讨论线程和进程的优缺点,线程异常和用途,线程安全问题,锁的概念,死锁现象,生产者消费者模型,BlockingQueue,POSIX信号量,环形队列以及在Linux环境下如何全面应用线程池。

什么是线程

  • 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序 列”
  • 一切进程至少都有一个执行线程
  • 线程在进程内部运行,本质是在进程地址空间内运行
  • 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
  • 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程
    执行流

站在内核角度看进程(承担系统资源分配的实体叫进程)

在这里插入图片描述

站在内核的角度看进程,进程是操作系统中的一个重要概念,内核对进程的管理涉及到多个方面:

  • 进程控制块(PCB):
    内核维护着一个数据结构,称为进程控制块(PCB),用于存储进程的状态信息。这包括进程的程序计数器、寄存器的状态、内存管理信息、打开的文件描述符、进程优先级等。
  • 调度器:
    内核负责进程的调度,以确定哪个进程将在处理器上执行。调度算法的选择可以影响系统的性能和响应时间。
  • 地址空间:
    内核为每个进程分配独立的地址空间,包括代码段、数据段、堆、栈等。地址空间的切换是通过上下文切换来完成的,这涉及到保存和恢复进程的寄存器和页表等信息。
  • 系统调用:
    进程通过系统调用与内核进行交互。系统调用提供了一种进程请求内核服务的机制,例如读写文件、分配内存、创建进程等。
  • 进程的创建与撤销:
    内核负责创建新的进程,这涉及到复制父进程的地址空间、文件描述符等信息。同时,当一个进程终止时,内核需要释放其占用的资源。
  • 进程间通信:
    内核提供了各种机制用于进程间的通信,如管道、消息队列、信号、共享内存等。这些机制使得不同进程之间可以进行数据交换。
  • 信号和中断处理:
    内核负责处理进程接收到的信号,这可能导致进程的某些操作,比如终止或忽略信号。同时,内核也要处理硬件中断,以响应外部设备的事件。
  • 资源管理:
    内核负责管理系统资源,包括处理器时间、内存、文件系统等。这包括对资源的分配和释放,以及对资源的调度和保护。

总的来说,内核是操作系统的核心组件,负责对进程进行管理和调度,以实现多任务和多用户的并发执行。内核提供了一组接口和机制,使得进程可以与硬件和其他进程进行交互,同时保障系统的稳定性和安全性。
进程并不是通过task_struct来衡量的,除了task_struct之外,一个进程还要有进程地址空间、文件、信号等等,合起来称之为一个进程。

站在内核角度看线程(承担执行任务的实体叫线程,共享其所属进程的资源。)

在这里插入图片描述

站在内核的角度看线程,线程是在进程内部执行的轻量级任务单元。与进程相比,线程共享相同的地址空间和资源,因此线程之间的切换比进程更加高效。以下是内核在处理线程时的一些关键方面:

  • 线程控制块(TCB):
    类似于进程控制块,内核维护线程控制块用于存储线程的状态信息。这包括程序计数器、寄存器的状态、栈指针、线程优先级等。
  • 调度器:
    内核负责线程的调度,确定哪个线程在处理器上执行。线程调度通常比进程调度更加频繁,因为线程在同一地址空间内运行,切换的开销相对较小。
  • 共享资源:
    由于线程共享进程的地址空间,因此它们可以直接访问相同的内存和文件。但这也引入了需要同步共享资源的问题,例如使用互斥锁或其他同步机制来防止竞态条件。
  • 上下文切换:
    内核需要管理线程之间的上下文切换。当一个线程被挂起,另一个线程需要执行时,内核会保存当前线程的状态并加载下一个线程的状态。
  • 线程的创建和销毁:
    内核负责在进程中创建和销毁线程。线程的创建通常涉及分配一个新的线程控制块和栈空间。当线程完成任务或被显式终止时,内核需要释放相关资源。
  • 信号处理:
    内核处理线程接收到的信号,这可能导致线程的某些操作,如终止或忽略信号。线程也可以设置信号处理程序来处理异步事件。
  • 多核处理:
    在多核系统中,内核需要有效地将线程分配到不同的处理器核心上,以充分利用系统的性能。
  • 异常处理:
    内核需要处理线程引发的异常,例如访问违例或除以零等异常情况。

总体而言,内核在处理线程时需要考虑资源共享、同步、调度和上下文切换等方面,以确保线程能够有效地运行并协同工作。线程的引入提供了更细粒度的并发控制,使得程序可以更有效地利用多核处理器的优势。

线程VS进程(数据结构角度)

  • 进程是资源分配的基本单位
  • 线程是调度的基本单位
  • 线程共享进程数据,但也拥有自己的一部分数据:

线程ID、 一组寄存器 、 栈 、errno、 信号屏蔽字、 调度优先级

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

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

Process Control Block (PCB):

字段作用
进程信息:PCB 存储了一个进程的基本信息,如进程标识符(PID)、状态、程序计数器(PC)等。
寄存器集合:PCB 包含了进程在执行过程中寄存器的内容,这些寄存器的状态用于在进程切换时保存和恢复现场。
内存管理信息:PCB 记录了进程的地址空间信息,包括代码段、数据段、堆、栈等。
文件描述符表:PCB 包含了进程打开的文件列表和相应的文件描述符。
进程调度信息:包括进程的优先级、调度状态等信息,用于操作系统的进程调度。
进程控制信息:包括父进程、子进程关系,信号处理信息等。

Thread Control Block (TCB):

字段作用
线程信息:TCB 包含了线程的标识符、状态(运行、就绪、阻塞等)等基本信息。
寄存器集合:类似于 PCB,TCB 也包含了线程在执行过程中寄存器的内容,用于保存和恢复现场。
栈指针:TCB 记录了线程的栈指针,指向线程的执行栈。
调度信息:包括线程的优先级、调度状态等,用于线程调度。
线程私有存储:每个线程有自己的私有存储空间,TCB 记录了这部分存储的信息。
所属进程信息:TCB 可能包含了所属进程的一些信息,如进程的 PID。

小结

  1. 对比两者可以发现,线程就是轻量级的进程
    在这里插入图片描述

  2. CPU能分清进程和线程吗?
    站在CPU角度,实际是分不清线程和进程的,也不需要分清楚,因为操作系统只关心每个PCB(task_struct)的调度——也就是每个执行流的调度,对每个PCB进行管理即可。

  3. Linux中存在线程吗?
    综上可知,Linux下并不存在真正的多线程,而是用进程模拟的!只是线程的执行粒度比进程更细!

  4. Linux中的线程系统调用是真正意义上的线程系统调用吗?
    Linux中都没有真正意义上的线程了,那么自然也没有真正意义上的线程相关的系统调用了。但Linux 提供了系统调用来创建和管理这些轻量级进程(即线程),例如 clone() 系统调用。clone() 允许创建新的执行线程,并且可以选择与调用者共享内存空间、文件描述符和其他资源。这个系统调用提供了创建轻量级进程(线程)的功能。

1. 线程和进程的优缺点

1.1 进程(Process)

特点

  • 独立性: 进程是程序的执行实例,具有独立的内存空间、文件描述符、寄存器集合和其他系统资源。每个进程都是独立的,相互隔离,彼此不会直接共享内存空间,需要通过显式的IPC(进程间通信)机制进行通信。
  • 资源分配: 每个进程都有自己的地址空间和系统资源,包括堆、栈、代码段、全局变量等。进程之间的切换开销相对较高,因为切换进程需要保存和恢复整个上下文。
  • 创建开销: 创建新进程的开销较大,因为需要复制父进程的地址空间及其相关资源,然后进行修改。
  • IPC通信: 进程之间的通信需要使用专门的IPC机制,如管道、消息队列、信号、共享内存和套接字等。 稳定性: 进程之间的隔离性有利于系统的稳定性,一个进程崩溃通常不会影响其他进程。

优点:

  • 独立性: 进程之间相互独立,一个进程的错误不会影响其他进程的稳定性,提高了系统的可靠性。

  • 安全性: 进程拥有独立的地址空间,一个进程无法直接访问另一个进程的数据,从而提高了系统的安全性。

  • 并发性: 进程允许多个任务同时执行,提高了系统的并发性和处理能力。

  • 灵活性: 进程可以独立开发、测试、调试和部署,提供了灵活性和可维护性。

资源共享: 进程之间可以通过进程间通信(IPC)机制来共享数据和信息,实现资源共享。

缺点:

  • 资源消耗: 每个进程都需要独立的内存空间、文件描述符等系统资源,因此创建和维护进程会占用较多的系统资源

  • 切换开销: 进程切换需要保存和恢复进程的状态,这会引起一定的开销,特别是在多进程并发执行时。

  • 复杂性进程之间的独立性和隔离性使得它们相对复杂,涉及到进程间通信和同步的问题。

  • 通信困难: 进程之间的通信需要额外的机制,如管道、消息队列、共享内存等,这增加了系统的复杂性。

  • 同步问题: 进程间的同步需要考虑临界区、互斥、信号量等问题,处理不当容易引发死锁和竞态条件。

1.2 线程(Thread)

特点

  • 共享性: 线程是进程内的执行单元,同一进程内的线程共享相同的地址空间、文件描述符和其他进程相关资源。因此,线程之间可以更方便地共享数据。
  • 资源共享: 线程之间可以直接访问同一进程的共享变量,不需要特殊的通信机制。这种共享使得线程间的通信更加高效。
  • 创建开销: 创建线程的开销较小,因为线程共享进程的地址空间和资源,创建时只需分配一些新的线程特定的资源。
  • 调度: 在Linux下,线程是由内核调度的基本单位,线程调度相对于进程调度更加轻量级。
  • 多核利用: 多线程程序可以更好地利用多核处理器的性能,因为线程可以并行执行,而进程的并行执行需要更多的系统资源。

优点:

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

缺点:

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

2.进程和线程创建对比

进程创建:

fork 函数:

  • 功能: 创建一个新的进程,称为子进程,该子进程是调用进程的副本,拥有相同的代码、数据和资源。
  • 特点: 子进程获得父进程的副本,但是它们运行在不同的地址空间。子进程的执行是从 fork 调用的位置开始的。

在这里插入图片描述

pid_t child_pid = fork();
if (child_pid == 0) {
    // 子进程执行的代码
} else if (child_pid > 0) {
    // 父进程执行的代码
} else {
    // 创建进程失败的处理代码
}

vfork 函数:

  • 功能: 类似于 fork,但是 vfork 的子进程共享父进程的地址空间,因此在子进程中对内存的修改会影响到父进程。
  • 特点: vfork 主要用于创建临时的子进程,它在父进程的地址空间中运行,直到调用 exec 或 _exit。

- 示例:

pid_t child_pid = vfork();
if (child_pid == 0) {
    // 子进程执行的代码
    _exit(0); // 或者 exec 函数
} else if (child_pid > 0) {
    // 父进程执行的代码
} else {
    // 创建进程失败的处理代码
}

线程创建(原生线程库pthread):

  • 由来:在Linux中,站在内核角度没有真正意义上线程相关的接口,但是站在用户角度,当用户想创建一个线程时更期望使用thread_create这样类似的接口,而不是vfork函数,因此系统为用户层提供了原生线程库pthread。
  • 意义: 原生线程库pthread的设计和实现旨在提供一套可移植、高性能、可扩展的多线程编程接口,使得开发者能够更容易地编写并发程序,而无需过多关注底层系统的细节

pthread_create 函数:

  • 函数解析:
#include <pthread.h>

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine) (void *), void *arg);
参数名作用
thread是用于存储新线程ID的指针。
attr是线程的属性,可以为NULL,表示使用默认属性。
start_routine是新线程的起始函数。
arg是传递给start_routine的参数。
  • 功能: 创建一个新的线程。
  • 特点: 新线程与调用线程共享相同的地址空间和其他资源,但有自己的独立栈。

- 示例:

#include <pthread.h>

void* thread_function(void* arg) {
    // 新线程执行的代码
    return NULL;
}

int main() {
    pthread_t thread_id;
    int result = pthread_create(&thread_id, NULL, thread_function, NULL);
    if (result != 0) {
        // 创建线程失败的处理代码
    }
    // 主线程执行的代码
    pthread_join(thread_id, NULL); // 等待新线程结束
    return 0;
}

对比之下,如果是在进程层面进行等待,通常会使用类似于wait或者waitpid的系统调用来等待子进程的结束。

#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>

int main() {
    pid_t child_pid = fork();

    if (child_pid == -1) {
        // 处理 fork 失败的情况
        perror("fork");
        return 1;
    }

    if (child_pid == 0) {
        // 子进程执行的代码
        // ...
        return 0;
    } else {
        // 父进程执行的代码
        int status;
        pid_t terminated_pid = waitpid(child_pid, &status, 0);

        if (terminated_pid == -1) {
            // 处理 waitpid 失败的情况
            perror("waitpid");
            return 1;
        }

        if (WIFEXITED(status)) {
            // 子进程正常退出
            printf("Child process %d exited with status %d\n", terminated_pid, WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            // 子进程因信号而结束
            printf("Child process %d terminated by signal %d\n", terminated_pid, WTERMSIG(status));
        }
    }

    return 0;
}

总的来说,线程和进程的等待机制有一些不同,主要是因为线程共享同一地址空间,而进程有独立的地址空间。在线程中,我们使用pthread_join等待线程的结束,而在进程中,可以使用waitpid等待子进程的结束。

3. 线程异常和用途

3.1 线程异常

常见异常:

  1. 死锁: 两个或多个线程无限等待对方释放资源。
  2. 饥饿: 一个或多个线程无法获得所需的资源。
  3. 优先级反转: 低优先级线程持有资源,高优先级线程等待。
  4. 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃

线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出

3.2 线程用途

  1. 并行计算,提高计算性能。
  2. 异步编程,处理IO操作等耗时任务。
  3. 多任务处理,提高系统响应速度。

抢票案例

每当放假,就是抢票高峰期,我们用不同终端(手机,电脑)去网页上抢票也就是一个多线程应用案例

  • 原理:我们模拟多人同时抢票场景,放票1000张,用3个终端去进行抢票
  • 预期结果:抢到票数=0结束

在这里插入图片描述

#include <iostream>
#include <thread>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
#include <cstdio>

using namespace std;
// 如果多线程访问同一个全局变量,并对它进行数据计算,多线程会互相影响吗?
int tickets = 1000; // 在并发访问的时候,导致了我们数据不一致的问题!

void *getTickets(void *args)
{
    (void)args;
    while(true)
    {
        if(tickets > 0)
        {
            usleep(1000);
            printf("%p: %d\n", pthread_self(), tickets);
            tickets--;
        }
        else{
            break;
        }
    }
    return nullptr;
}

int main()
{
    pthread_t t1,t2,t3;
    // 多线程抢票的逻辑
    pthread_create(&t1, nullptr, getTickets, nullptr);
    pthread_create(&t2, nullptr, getTickets, nullptr);
    pthread_create(&t3, nullptr, getTickets, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
}

在这里插入图片描述
结果:
在这里插入图片描述

分析结果,得出问题——为什么抢到了0张和-1张票呢???(下一章解答您心中疑问)

文章来源:https://blog.csdn.net/sun_0228/article/details/135182197
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。