Linux系统编程:线程互斥

目录

一. 与线程互斥相关的概念

二. 线程安全问题

2.1 多个执行流访问临界区资源引发线程安全问题

2.2 可重入函数和线程安全的关系

三. 互斥锁 mutex

3.1 互斥锁功能

3.2 互斥锁的使用 

3.3 互斥锁的实现原理

四. 死锁问题 

四. 总结


一. 与线程互斥相关的概念

  • 临界资源:被多个执行流共享的那部分资源,称为临界资源。
  • 临界区:每个线程内部访问临界资源的那部分代码,称为临界区。
  • 互斥:互斥保证任何时刻只有一个执行流进入临界区访问临界资源,用于对临界区资源进行保护,从而保证线程安全。
  • 原子性:一个只有两种状态的操作,要么不执行操作,执行就一定全部完成,不具有中间状态,这样的操作我们说它具有原子性。

二. 线程安全问题

2.1 多个执行流访问临界资源引发线程安全问题

如代码2.1所示,就是一个典型的线程不安全代码,代码2.1为一个多线程抢票程序,这段代码的目的是让多个线程并发执行,从而提高抢票的效率,我们希望tickets为0的时候就不再继续抢票。但是,如图2.1,编译并运行代码,我们发现,tickets最后居然 < 0 了,这就是不安全造成的问题。

代码2.1:线程不安全的抢票程序

#include <iostream>
#include <cassert>
#include <pthread.h>
#include <unistd.h>

int g_tickets = 100;   // 全局变量,表示剩余票数

void *getTickets(void *args)
{
    char *para = (char*)args;  
    while(true)
    {
        if(g_tickets > 0)
        {
            usleep(1000);
            printf("%s, tickets:%d\n", para, g_tickets);
            --g_tickets;
        }
        else
        {
            break;
        }
    }

    return nullptr;
}

int main()
{
    pthread_t tid[4];

    // 创建线程1-4
    pthread_create(tid, nullptr, getTickets, (char*)"thread 1");
    pthread_create(tid + 1, nullptr, getTickets, (char*)"thread 2");
    pthread_create(tid + 2, nullptr, getTickets, (char*)"thread 3");
    pthread_create(tid + 3, nullptr, getTickets, (char*)"thread 4");

    // 等待线程
    for(int i = 0; i < 4; ++i)
    {
        pthread_join(tid[i], nullptr);
    }
    std::cout << "main thread over" << std::endl;

    return 0;
}
图2.1 代码2,1运行结果(部分)

造成上述错误的问题,正是由多个线程同时进入getTickets函数中的临界区引发的。

首先来分析,为什么会出现tickets <= 0,但 if(g_tickets > 0) 内部的代码还在被运行的情况,可以按照这样的链路来理解:

  • getTickets函数中,if内部为临界区,访问临界资源g_tickets,假设有2个线程(称为线程A和线程B)判断if成立,进入了临界区。
  • 假如线程A的运气很不好,在进入if后,马上就被CPU给切换走了,线程B开始被调度,在线程B中执行了g_tickets--操作,将g_tickets的值减到了0,此时线程B终止,换上线程A继续执行。
  • 此时的线程A执行流已经进入到了if内部,虽然g_tickets已经变为了0,但依旧不影响if内部的代码执行,这样就输出了tickets:0。
  • 如果线程A执行g_tickets--将g_tickets的值变为负数,那么其他已经进入到了if内部的线程,就会读到负数并输出,这样就造成了输出tickets<=0的问题。
图2.2 线程安全引发输出ticket小于0问题图解

不光是多线程进入if内部会引发线程安全问题,--g_tickets也会存在线程不安全,按照下面假设的逻辑链,来理解--g_tickets为什么会存在线程安全问题:

  • --/++指令,翻译成汇编代码后,有三条汇编指令会被先后执行:(1). 从物理内存中读取g_tickets到CPU寄存器中   (2). 执行--/++运算   (3). 将--/++后的g_tickets值写回内存。
  • --g_tickets的三条汇编指令在运行的过程中,在任何位置都可能被打断。
  • 假设线程A在第二条汇编指令执行完成后就被切换了,此时CPU寄存器中记录g_tickets的值为99,这个99被存储到线程A的PCB中,线程B被换上运行。
  • 再假设线程B执行多次--g_tickets后才被切换,线程B被切换走的时候,已经将g_tickets减到了10并写回了内存。
  • 当内存中记录g_tickets=10时,将此前被切换走的线程A拿到CPU从上次中断的位置开始运行,然而,由于进程A的PCB中记录了g_tickets的值为上次--g_tickets但还没来的及写入内存的99,g_tickets=99被放入CPU寄存器,--g_tickets的第三条汇编指令将99写入了物理内存,前面线程B将g_tickets减到10的工作白做了!!

上面就是线程不安全的现象,在项目开发过程中,应当采取编写可重入函数、加锁等多线程编程思想,来保证线程安全。

2.2 可重入函数和线程安全的关系

可重入函数、不可重入函数和线程安全的概念:

  • 可重入函数:多个执行流同时进入函数,不会影响运行结果的函数,称为可重入函数。
  • 不可重入函数:多个执行流同时进入函数,可能引发异常的函数,称为不可重入函数。
  • 线程安全:多个执行流并发执行同一段代码,不会出现运行结果不同的代码,我们称其是线程安全的。

常见的不可重入函数的:

  • 调用了 malloc / new 动态申请内存资源的函数(内存资源是由双链表管理的)。
  • 进行了IO操作的函数(涉及缓冲区,有可能存在无序输入/输出)。
  • 内部更改了全局变量或者静态变量的函数。

函数可重入与线程安全的关系:

  • 如果一个函数是可重入的,那么它一定是线程安全的。
  • 一个线程安全的程序,可以存在不可重入的函数(使用互斥锁避免多执行流访问临界资源)

三. 互斥锁 mutex

3.1 互斥锁功能

如代码2.1,多个线程同时进入临界区运行,会引发线程安全问题,那么,为了避免这样的问题,引入了互斥锁,通过将临界区锁死,来避免多执行流进入。

一块被锁死的临界区,就只允许一个执行流进入,其他执行流若想进入临界区,那么就必须阻塞等待,直到解锁。

这样,被加锁的区域,就由多线程下的并发执行,变为只能串行执行

由于互斥锁避免了多执行流进入临界区,对临界区资源进行了保护,从而避免了线程安全问题。

如3.1为互斥锁实现的功能,执行流在进入到临界区之前为并行执行,由于临界区上锁,临界区内只能串行执行,出了临界区就解锁,从而恢复并行执行。

图3.1 互斥锁功能图解

3.2 互斥锁的使用 

创建互斥锁pthread_mutex_t:

  • 在全局或者具备,定义 pthread_mutex_t 类型的对象,就完成了互斥锁的创建。
  • 创建名为mtx的互斥锁语法:pthread_mutex_t mtx

互斥锁的初始化:

  • 有两种方式可以实现对互斥锁的初始化
  • 对于全局的互斥锁,可以直接将互斥锁对象赋值为PTHREAD_MUTEX_INITIALIZER,实现的语法为:pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER。
  • 对于局部互斥锁,可以使用pthread_mutex_init函数来初始化。

pthread_mutex_init -- 初始化互斥锁

函数原型:int pthread_mutex_init(pthread_mutex_t *mutex,  const pthread_mutexattr_t *restrict attr)

函数参数:

  • mutex -- 指向被初始化的互斥锁的指针。
  • attr -- 初始化互斥锁的属性,一般传nullptr表示默认属性

返回值:如果成功返回0,如果失败返回非0错误码。

互斥锁的销毁:

  • 可调用pthread_mutex_destroy函数销毁指定互斥锁。
  • 对于局部互斥锁才需要人工销毁,全局互斥锁进程结束便会自动销毁。

pthread_mutex_destroy -- 销毁互斥锁

函数原型:int pthread_mutex_destroy(pthread_mutex_t *mutex);

函数参数:mutex -- 指向被销毁的互斥锁的指针。

返回值:如果成功返回0,如果失败返回非0错误码。

对临界区代码上锁:

  • 使用pthread_mutex_lock函数可以实现上锁
  • 从pthread_mutex_lock函数被调用开始直到解锁,它们之间的代码都只允许一个执行流进入。
  • 对临界区才需要上锁,如果对非临界区上锁,会降低效率。

pthread_mutex_lock -- 临界区代码上锁

函数原型:int pthread_mutex_lock(pthread_mutex_t *mutex)

函数参数:mutex -- 指向使用的锁的指针。

返回值:如果成功返回0,如果失败返回非0错误码。

解锁:

  • 使用函数pthread_mutex_unlock函数解锁。
  • 一旦离开临界区,应当马上解锁,避免降低效率。

pthread_mutex_unlock -- 解锁

函数原型:int pthread_mutex_unlock(pthread_mutex_t *mutex)

函数参数:mutex -- 指向被解开的锁的指针。

返回值:如果成功返回0,如果失败返回非0错误码。

代码3.1在2.1的基础之上,定义了全局互斥锁,在进入getTicket函数内的if条件判断式之前,调用pthread_mutex_lock函数对临界区上锁,当进行完IO操作、访问完临界资源g_tickets后,马上进行解锁,这样就保证了线程安全性,不会出现输出ticket为0或为负的情况。

代码3.1:使用全局互斥锁对临界区上锁

#include <iostream>
#include <ctime>
#include <cstdlib>
#include <vector>
#include <string>
#include <cassert>
#include <pthread.h>
#include <unistd.h>

#define THREAD_NUM 5   // 子线程数量
int g_tickets = 100;   // 全局变量,表示剩余票数
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;   // 定义并初始化全局互斥锁

void *getTickets(void *args)
{ 
    std::cout << (char*)(args) << std::endl;
    while(true)
    {
        // 加锁
        int n = pthread_mutex_lock(&mutex);
        assert(n == 0);
        if(g_tickets > 0)
        {
            usleep(1000);
            printf("%s, tickets:%d\n", (char*)args, g_tickets);
            --g_tickets;
            n = pthread_mutex_unlock(&mutex);  // 解锁
            assert(n == 0);
        }
        else
        {
            n = pthread_mutex_unlock(&mutex);  // 解锁
            assert(n == 0);
            break;
        }

        usleep(rand() % 2000);
    }

    return nullptr;
}

int main()
{
    srand((unsigned int)time(nullptr) ^ getpid());  // 种下随机数种子

    pthread_t tid[THREAD_NUM];
    
    // 生成线程命名
    std::vector<std::string> name(THREAD_NUM, "thread ");
    for(int i = 0; i < THREAD_NUM; ++i)
    {
        name[i] += std::to_string(i + 1);
    }

    // 创建线程
    for(int i = 0; i < THREAD_NUM; ++i)
    {
        int n = pthread_create(tid + i, nullptr, getTickets, (void*)name[i].c_str());
        assert(n == 0);
    }

    // 主线程等到子线程退出
    for(int i = 0; i < THREAD_NUM; ++i)
    {
        int n = pthread_join(tid[i], nullptr);
        assert(n == 0);
    }

    return 0;
}

代码3.2则使用了在main函数中定义局部互斥锁的方式,main函数中定义的局部互斥锁,需要传递给线程函数getTicket,但是,线程函数的参数为void*类型,我们不但希望传递互斥锁,还希望传入另一个char*类型的参数,这里采用定义struct结构体的方法,在struct threadData中定义string类型和pthread_mutex_t类型指针,将指向struct threadData类型对象的指针充当参数传给线程函数,这样就实现了在局部定义互斥锁并传给线程函数。

代码3.2:在局部(main函数)中定义互斥锁

#include <iostream>
#include <string>
#include <cassert>
#include <pthread.h>
#include <unistd.h>

#define THREAD_NUM 5   // 子线程数量
int g_tickets = 100;   // 全局变量,表示剩余票数

// 用于向线程函数传参的结构体
struct threadData
{
    std::string _name;
    pthread_mutex_t *_ptx;

    threadData(const std::string& name, pthread_mutex_t *ptx)
        : _name(name)
        , _ptx(ptx)
    { }
};

void *getTickets(void *args)
{ 
    threadData *pth = (threadData*)args;
    while(true)
    {
        // 加锁
        int n = pthread_mutex_lock(pth->_ptx);
        assert(n == 0);
        if(g_tickets > 0)
        {
            usleep(1000);
            printf("%s, tickets:%d\n", pth->_name.c_str(), g_tickets);
            --g_tickets;
            n = pthread_mutex_unlock(pth->_ptx);  // 解锁
            assert(n == 0);
        }
        else
        {
            n = pthread_mutex_unlock(pth->_ptx);  // 解锁
            assert(n == 0);
            break;
        }
    }

    delete pth;
    return nullptr;
}


int main()
{
    // 定义并初始化局部线程锁
    pthread_mutex_t mutex;
    pthread_mutex_init(&mutex, nullptr);

    pthread_t tid[THREAD_NUM];
    
    // 创建子线程
    for(int i = 0; i < THREAD_NUM; ++i)
    {
        std::string name = "thread ";
        name += std::to_string(i + 1);
        struct threadData* pth = new threadData(name, &mutex);
        int n = pthread_create(tid + i, nullptr, getTickets, (void*)pth);
        assert(n == 0);
    }

    // 等待子线程
    for(int i = 0; i < THREAD_NUM; ++i)
    {
        int n = pthread_join(tid[i], nullptr);
        assert(n == 0);
    }

    pthread_mutex_destroy(&mutex);

    return 0;
}

3.3 互斥锁的实现原理

如果线程函数对一个全局变量(临界资源)做修改,那么,我们就认为,这个线程函数在访问临界资源,如果不加锁,它就是线程不安全的。但是,互斥锁也是临界资源啊,为什么多个线程使用一个互斥锁,不会出现线程安全问题呢?

这就要涉及到互斥锁上锁的底层实现了,如果上锁和解锁的操作都是原子的,不会出现中间状态,那么,这个互斥锁就是线程安全的。

站在汇编的角度,如果只执行一条汇编指令,我们认为单条汇编指令是原子的。而swap或exchange指令,可以实现将CPU寄存器中的数据和内存中的数据做交换,这个交换指令是原子的,互斥锁正是运用了这种交换,来保证上锁和解锁操作的原子性。

图3.2为上锁操作的伪代码和保证互斥锁线程安全的原理图,结合伪代码,并想象下面的场景,来理解互斥锁能保证线程安全的原因:

  • 假设线程A正在被调度,线程A向寄存器中写入了0,然后寄存器数据与物理内存中mutex数据进行了交换,执行完swap后,线程A被切走,线程B被调度。
  • 线程A在被切换的时候,要带走CPU寄存器中的相关数据到其PCB中去。
  • 线程B先后执行move向寄存中写入0,执行swap交换寄存器和mutex的数据,此时寄存器中的数据变为了0,if条件判断不成立,线程B挂起等待,切换回线程A继续调度。
  • 线程A再次调度时要把PCB中的数据写回寄存器,此时if判断成立,线程A就可以执行临界区代码了。
  • 综上,我们发现,如果线程A拿到了锁(if判断寄存器数据 > 0 成立),那么,线程B及其他任意一个线程,都无法拿到锁,也就无法进入临界区执行代码。
  • 由于是通过从CPU中读取值来判断是否拿到锁,上锁是通过swap来完成的,因此最初的mutex=1中的那个1,被每个线程换来换去,但始终不会拷贝,这个1只有一份,保证了不会有两个线程同时拿到锁,也就保证了临界区在某一时刻只能有1个执行流进入。
图3.2 互斥锁保证线程安全的原因图解

解锁的原理也非常简单,只需要向物理内存存储mutex的区域写入1,就表示这个锁被释放掉了,再次swap的时候,调度优先级高的线程就可以拿到锁,再次进入临界区执行代码,图3.3为上锁和解锁的伪汇编代码。

图3.3 上锁和解锁的伪代码

四. 死锁问题 

在多线程中,如果每个线程都占用一定的资源,并且不释放资源,而每个线程都需要相互申请被其它线程所占用的资源,而造成整个进程处于永久等待状态的现象,叫做死锁。

图4.1展示了一种死锁的场景,线程1和线程2需要申请锁A和锁B,其中线程1先申请锁A再申请锁B,而线程2先申请锁B再申请锁A,如果线程1申请完锁A之后就被切换去执行线程2,而线程2就会先申请锁B,当线程2申请锁A的时候,由于线程1持有锁A,线程2不能申请成功,并且当线程1再次被调度想申请锁B的时候,由于线程2占有锁B,也无法成功申请。

这样就造成了线程1和线程2互相索要对方占有的资源,而使它们处于永久等待状态的问题,这种现象被称为死锁。

死锁问题,类似于智能指针shared_ptr的循环引用造成两处资源的释放互相依赖对方的释放,而谁也无法释放的问题。

图4.1 死锁问题

五. 总结

  • 在多线程场景下,如果对临界区代码不予以加锁保护,会出现运行结果不可预期的线程安全问题,如果一个函数是可重入的,那么它一定是线程安全的。
  • 对临界区代码加锁,保证同一时刻只能有一个执行流进入执行,上锁是通过swap/exchange汇编指令,来保证原子性,从而保证上锁操作不会出现线程安全问题的。
  • 如果几个线程互相申请其他线程已经占用的资源,那么就会出现死锁问题,所有线程都会一直处于等待状态。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/93364.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

9.oracle中sign函数

在Oracle/PLSQL中, sign 函数返回一个数字的正负标志. 语法如下&#xff1a;sign( number ) number 要测试标志的数字. If number < 0, then sign returns -1. If number 0, then sign returns 0. If number > 0, then sign returns 1. 应用于: Oracle 8i, Oracle …

mysql my.ini、登录、用户相关操作、密码管理、权限管理、权限表,角色管理

my.ini 配置文件格式 登录mysql mysql -h hostname | IP -P port -u username -p database -e “select 语句”&#xff1b; 创建用户、修改用户、删除用户 create user ‘zen’ identified by ‘密码’ ## host 默认是 % create user ‘zen’‘localhost’ identified by ‘密…

<高阶数据结构>图

图 必要概念大致用途 存图邻接矩阵邻接表 遍历BFS(广度优先)DFS(深度优先) 最小生成树Kruskal算法Prim算法 寻最短路径Dijkstra算法 必要概念 图根据有无方向分为&#xff0c;有向图和无向图 组成&#xff1a;G (V, E) 顶点集合 V边的集合 E G(Graph),V(Vertex),E(Edge) 图可…

Docker容器:docker consul的注册与发现及consul-template

Docker容器&#xff1a;docker consul的注册与发现及consul-template守护进程 一.docker consul的注册与发现介绍 1.什么是服务注册与发现 &#xff08;1&#xff09;服务注册与发现是微服务架构中不可或缺的重要组件。 &#xff08;2&#xff09;为解决服务都是单节点的&a…

李沐pytorch学习-BatchNormalization

一、意义 在使用较深的网络时&#xff0c;BatchNormalization&#xff08;批量归一化&#xff09;几乎是必需的&#xff0c;可以加速收敛。 对于图1所示的全连接层神经网络&#xff0c;输出节点的GroundTruth为&#xff0c;损失函数为&#xff0c;则损失对权重的梯度为&#xf…

接口测试总结分享(http与rpc)

接口测试是测试系统组件间接口的一种测试。接口测试主要用于检测外部系统与系统之间以及内部各个子系统之间的交互点。测试的重点是要检查数据的交换&#xff0c;传递和控制管理过程&#xff0c;以及系统间的相互逻辑依赖关系等。 一、了解一下HTTP与RPC 1. HTTP&#xff08;H…

YOLOv5屏蔽区域检测(选择区域检测)

YOLOv5屏蔽区域检测以及选择区域检测 前期准备labelme选择mask区域 代码改动 前期准备 思路就是通过一个mask掩膜&#xff0c;对我们想要屏蔽或者选择的区域进行遮挡处理&#xff0c;在推理的时候&#xff0c;将有mask掩膜的图像输入&#xff0c;将最后的结果显示在原始图像上…

【Go 基础篇】Go语言中的自定义错误处理

错误是程序开发过程中不可避免的一部分&#xff0c;而Go语言以其简洁和高效的特性闻名。在Go中&#xff0c;自定义错误&#xff08;Custom Errors&#xff09;是一种强大的方式&#xff0c;可以为特定应用场景创建清晰的错误类型&#xff0c;以便更好地处理和调试问题。本文将详…

基于微信小程序的汽车租赁系统的设计与实现ljx7y

汽车租赁系统&#xff0c;主要包括管理员、用户二个权限角色&#xff0c;对于用户角色不同&#xff0c;所使用的功能模块相应不同。本文从管理员、用户的功能要求出发&#xff0c;汽车租赁系统系统中的功能模块主要是实现管理员后端&#xff1b;首页、个人中心、汽车品牌管理、…

算法通关村十三关 | 进制转换问题处理模板

1. 七进制数 题目&#xff1a;LeetCode504&#xff1a;504. 七进制数 - 力扣&#xff08;LeetCode&#xff09; 思路 进制转换&#xff0c;对几转换就是对几求余&#xff0c;最后将所有的余数反过来即可、如果num< 0&#xff0c;先取绝对值&#xff0c;再进行操作。 100转7…

MYSQL表的增删改查(单表)

文章目录 一、CRUD二、creat(新增)三、查询&#xff08;Retrieve&#xff09;四、修改&#xff08;update&#xff09;五、删除&#xff08;Delete&#xff09; 一、CRUD SQL 最核心的就是增删改查&#xff0c;后端开发工作中&#xff0c;遇到的最核心的操作也是这个 二、creat…

2023高教社杯数学建模思路 - 复盘:人力资源安排的最优化模型

文章目录 0 赛题思路1 描述2 问题概括3 建模过程3.1 边界说明3.2 符号约定3.3 分析3.4 模型建立3.5 模型求解 4 模型评价与推广5 实现代码 建模资料 0 赛题思路 &#xff08;赛题出来以后第一时间在CSDN分享&#xff09; https://blog.csdn.net/dc_sinor?typeblog 1 描述 …

Blazor 依赖注入妙用:巧设回调

文章目录 前言依赖注入特性需求解决方案示意图 前言 依赖注入我之前写过一篇文章&#xff0c;没看过的可以看看这个。 C# Blazor 学习笔记(10):依赖注入 依赖注入特性 只能Razor组件中注入所有Razor组件在作用域注入的都是同一个依赖。作用域可以看看我之前的文章。 需求 …

string类中的一些问题

前言&#xff1a;C中的string类是继承C语言的字符数组的字符串来实现的&#xff0c;其中包含许多C的字符串的相关知识的同时&#xff0c;也蕴含很多的类与对象的相关知识&#xff0c;在面试中&#xff0c;面试官总喜欢让学生自己来模拟实现string类&#xff0c;最主要是实现str…

azure data studio SQL扩展插件开发笔记

node.js环境下拉取脚手架 npm install -g yo generator-azuredatastudio yo azuredatastudio 改代码 运行 调试扩展&#xff0c;在visual studio code中安装插件即可 然后visual studio code打开进行修改运行即可 image.png 运行后自动打开auzre data studio了&#xff0c; 下面…

开源与专有软件:比较与对比

&#x1f337;&#x1f341; 博主猫头虎 带您 Go to New World.✨&#x1f341; &#x1f984; 博客首页——猫头虎的博客&#x1f390; &#x1f433;《面试题大全专栏》 文章图文并茂&#x1f995;生动形象&#x1f996;简单易学&#xff01;欢迎大家来踩踩~&#x1f33a; &a…

中文乱码处理

&#x1f600;前言 中文乱码处理 &#x1f3e0;个人主页&#xff1a;尘觉主页 &#x1f9d1;个人简介&#xff1a;大家好&#xff0c;我是尘觉&#xff0c;希望我的文章可以帮助到大家&#xff0c;您的满意是我的动力&#x1f609;&#x1f609; 在csdn获奖荣誉: &#x1f3c…

kubernetes deploy standalone mysql demo

kubernetes 集群内部署 单节点 mysql ansible all -m shell -a "mkdir -p /mnt/mysql/data"cat mysql-pv-pvc.yaml apiVersion: v1 kind: PersistentVolume metadata:name: mysql-pv-volumelabels:type: local spec:storageClassName: manualcapacity:storage: 5Gi…

JVM第一篇 认识java虚拟机

目录 1. 什么是java虚拟机 2. java虚拟机分类 2.1. 商用虚拟机 2.2. 嵌入式虚拟机 3.java虚拟机架构 4.java虚拟机运行过程 1. 什么是java虚拟机 传统意义上的虚拟机是一种抽象化的计算机&#xff0c;通过在实际的计算机上仿真模拟各种计算机功能来实现的&#xff0c;是操…

数据结构入门 — 链表详解_单链表

前言 数据结构入门 — 单链表详解* 博客主页链接&#xff1a;https://blog.csdn.net/m0_74014525 关注博主&#xff0c;后期持续更新系列文章 文章末尾有源码 *****感谢观看&#xff0c;希望对你有所帮助***** 系列文章 第一篇&#xff1a;数据结构入门 — 链表详解_单链表 第…