Linux内核

Linux内核基础

本文阐述系统为x86体系架构,idt,gdt,tss,系统启动引导过程均以x86架构为蓝本,arm可能不适用

Linux内核体系结构

nmi(not mask interrupt)不可屏蔽中断
系统启动时会读取RTC并赋值给jiffies
内核中定时器使用链表进行管理

每个进程都有LDT(局部描述符),即代码段和数据段。单处理器系统只有一个GDT,而在多处理器系统中每个CPU都有一个GDT,GDT(全局描述符)是各个寄存器地址

IDT(中断描述符表),其作用也类似于中断向量表,只是其中每个中断描述符项中除了含有中断服务程序地址以外,还包含有关特权级描述符类别等信息
中断向量表和中断描述符表IDT-CSDN

linux分段-CSDN

2024-02-22_21-06

TSS(任务状态段)就是特殊寄存器 + 通用寄存器的信息

2024-02-22_09-49

fork的实现

  • 申请内存空间
  • 把父进程的task_struct拷贝到子进程的task_struct并重新设置
  • 设置进程状态为可运行,返回pid

需要注意的是fork出的子进程的文件描述符比父进程大1,这也是Linux基础文章中现象的实现

2024-02-22_10-28

进程调度

在0.11内核中,进程调度分为调度和切换两个步骤,调度负责将用完时间片的进程挂起,并将进程的状态位改为 ,切换时为了效率采用汇编,它的作用是将那些设置状态位的进程加载进CPU寄存器中,即执行上下文切换的功能

进程销毁

以syscall_和do_开头的基本上是系统调用,即中断服务函数

  • 内核的销毁流程
    • 进入do_exit函数
      • 首先释放数据段和代码段占用的内存
      • 关闭进程打开的所有文件,对当前的目录和i节点进行同步
      • 如果进程是个会话头进程,则会终止会话中的所有进程
      • 改变进程运行状态为TASK_ZOMBIE,并且向父进程发送SIGCHLD信号,do_exit函数执行完毕
    • 在父进程中进行最后的处理工作,父进程运行子进程的时候一般都会运行wait或waitpid这两个函数(父进程等待某个子进程终止的函数),当父进程收到SIGCHLD信号时会终止僵死状态的子进程
      • 首先父进程会把子进程的运行时间(utime,stime)累加到自己的进程变量中,把要销毁的子进程的进程描述结构体进行释放,置空任务数组中的子进程任务槽

2024-02-22_12-05

Linux内核启动引导

操作系统的启动

Linux启动时的硬件信息是由bootloader传进来的

  • Linux启动流程
    • 首先由BIOS/bootloader进行一系列的硬件初始化和参数设置,并把bootsect.s从硬盘中某个固定地址搬到内存中的某个固定地址,需要注意在内核初始化需要关中断来防止初始化被中断。
    • bootsect.s将setup.s代码从磁盘中加载到紧接着bootsect.s的地方,最后跳转到setup.s中运行
    • setup.s负责将bootloader传进来的信息进行解析,关中断并加载system模块,使system模块正好覆盖bootsect.s(关中断是为了防止覆盖bootsect.s的代码后系统不能处理中断而导致崩溃)。设置LDT,GDT,IDT,而后设置中断芯片,进入到保护模式(svc32)运行,在代码的最后会跳转到head.s(0x00000000)执行
    • head.s加载内核运行时的各数据段寄存器,并重新设置IDT(防止被数据区覆盖),设置内存对齐以及分页,最后跳转到main.c开始运行,系统启动完成
  • Linux在启动的时候是如何拿到硬件参数的
    • 通过bootloader加载传入的参数(_atags_pointer),这个参数是一块指向存有硬件信息内存的首地址的指针,通过这个指针就可以获得硬件参数
  • Linux在初始化运行中都做了什么
    • 进行硬件的初始化(就是下文中各种段的设置),fork出0号进程

2024-02-22_16-13

2024-02-22_16-17

2024-02-22_16-37

系统第一个APP是根文件系统(linuxrc)

4.9.88内核main函数位于/init文件夹,调用流程如下

1
2
3
4
5
6
7
8
9
10
11
12
13
asmlinkage __visible void __init start_kernel(void)
static noinline void __ref rest_init(void)
pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags)
{
return _do_fork(flags|CLONE_VM|CLONE_UNTRACED, (unsigned long)fn,
(unsigned long)arg, NULL, NULL, 0);
}
kernel_thread(kernel_init, NULL, CLONE_FS);
static int __ref kernel_init(void *unused)//在内部启动系统第一个APP,根文件系统
/sbin/init
/etc/init
/bin/init
/bin/sh

内核main函数:

  • 在内核初始化的过程中会手动创建(fork出来)0号进程,它是所有进程的父进程
  • 之后在0号进程中分别打开标准输入,标准输出,标准错误的控制台句柄。并创建1号进程
  • 在1号进程中,首先打开/etc/rc(配置文件,类似启动项管理),而后执行shell程序(实际上是被转移到了busybox里面,因为/bin/sh是busy/box的软链接,而后在busybox内部初始化shell)

0号进程不会关闭,如果其他进程没有被执行,那么就执行0号进程,内部是pause函数

2024-02-22_13-35

init函数就是在不断地创建shell,并在子进程中执行,等子进程执行完毕后由父进程接收结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

void init(void)
{
int pid,i;

setup((void *) &drive_info);
(void) open("/dev/tty0",O_RDWR,0);
(void) dup(0);
(void) dup(0);
printf("%d buffers = %d bytes buffer space\n\r",NR_BUFFERS,
NR_BUFFERS*BLOCK_SIZE);
printf("Free mem: %d bytes\n\r",memory_end-main_memory_start);
if (!(pid=fork())) {//1号进程
close(0);
if (open("/etc/rc",O_RDONLY,0))
_exit(1);
execve("/bin/sh",argv_rc,envp_rc);
_exit(2);
}
if (pid>0)
while (pid != wait(&i))
/* nothing */;

//不断地创建进程
while (1) {
if ((pid=fork())<0) {
printf("Fork failed in init\r\n");
continue;
}
//如果在子进程中则打开tty并执行shell命令
if (!pid) {
close(0);close(1);close(2);
setsid();
(void) open("/dev/tty0",O_RDWR,0);
(void) dup(0);
(void) dup(0);
_exit(execve("/bin/sh",argv,envp));
}
//如果在父进程中则等待子进程处理的结果
while (1)
if (pid == wait(&i))
break;
printf("\n\rchild %d died with code %04x\n\r",pid,i);
sync();
}
_exit(0); /* NOTE! _exit, not exit() */
}

Linux启动时会进行异常的初始化,这些初始化函数分为两种,一类是设置用户特权的异常,一类是设置内核特权的异常

2024-02-22_20-07

内核通过分段将machine_desc结构体放于内存某一固定位置中,这些machine_desc用于linux识别设备

2024-02-22_21-23

此代码位于head-common.s,在head.s被调用
2024-02-22_21-27

2024-02-22_21-27_1

2024-02-22_21-34

Linux移植浅谈

Linux系统移植分为2步:

  • 进行初始化的适配,让板子能够运行到main函数
  • 进行驱动系统的移植

Linux内核移植必须能够在庞大的源码中找到所需代码

Linux内核文件系统

2024-02-28_14-51

2024-02-25_01-21_1

2024-02-25_01-24

文件系统由内核启动:
由bootloader传进来的taglist放入内存中某个段,由内核进行解析后(也就是字符串处理)将原有的init=linuxrc转换为comandline=linuxrc,如果bootloader没有定义init=linuxrc,那么内核会在下列文件夹中依次寻找,然后进入文件系统。默认情况下/sbin/init等文件是一个指向/bin/busybox的软链接,这个busybox只有在嵌入式系统中才有,对于Ubuntu而言对应的则是dash,而后由busybox启动文件文件系统

2024-02-23_13-31

busybox是一个极简的工具箱,它提供了基础的命令,与文件系统关系紧密

启动脚本位于/etc/rc.d

文件系统工作流程(以下过程均在busybox/init.c源码中的init_main函数中完成)

  • 传入参数与解析参数
    • 用户自定义/etc/inittab配置文件(读取文件原理类似设备树),内核将文件中的init_main进行读取,根据文件中的action参数形成一条action_list链表,链表内部包含的shell命令就是参数,如果用户没有指定参数,那么执行默认的参数
  • 使用参数
    • 逐个运行链表中的shell命令即可(实际上此时还没有启动,这时的命令只是命令行参数)

在init_main函数结尾,busybox会执行如下死循环,这个死循环会不停的做以下事情:

  • 执行run_action,然后等待其退出
  • 退出后再次执行run_action,不断的重复第一步

2024-02-24_09-28

一个文件系统需要什么?

  • /dev/console,没有了这个文件或者console初始化失败都会导致busybox无法启动
  • 需要init_main函数来初始化(执行启动脚本等),这由busybox提供
  • /etc.init.d/rcS脚本,如果不提供这个脚本会导致系统阻塞在文件系统启动之前,无法正常返回
  • 因为需要运行shell命令,因此需要busybox本身
  • 还需要busybox的运行环境glibc

分页:
当CPU开始运行程序时会从硬盘中加载若干页的代码到内存中,当CPU发现这些页并不足以运行程序时就会发出缺页中断(软中断),硬盘就会继续加载若干页代码到内存中

VFS(虚拟文件系统):将各种硬件抽象成为文件

文件系统结构

引导块:用于引导设备
超级块:该文件子系统的描述符,当位数超过某个值时会按照类似内存映射的方式进行块的映射
逻辑块位图:每一位对应一个逻辑块的使用情况,如果逻辑块使用则对应逻辑块位图对应位置1
i节点位图:同理,描述inode节点的使用情况
逻辑块:用来存储数据的数据存储单元
i节点:目录与磁盘的桥接,文件的属性描述

2024-03-04_16-44

2024-02-24_10-19
2024-02-24_10-21

高速缓冲区

高速缓冲区存储着块设备驱动程序的数据,并且当从块设备读取数据时,OS会首先在高速缓冲区中查找,找到了且是最新的数据,那么直接和高速缓冲区交互,如果没有找到或者不是最新的则需要在块设备或内存中查找

2024-03-28_12-56

哈希(hash)表

哈希表又称为散列表,由哈希函数和数组(也称为散列数组)构成,哈希表是为了解决遍历数组时间复杂度O(logn,以2为底)的问题,首先由输入元素经过哈希函数生成一个整数n,然后再把该元素放到arr[n]的位置,下次如果需要找到此元素,则可以通过hash函数生成一个整数n,从而在数组中找到该元素,这样就把查询的时间复杂度降为O(1)了。因此对于hash函数有如下要求:

  • 每一个独立的元素都应该有唯一的hash值与之对应
  • 每一个hash值都只对应一个元素

缓冲区头

2024-03-02_13-20

高速缓冲区结构中有个缓冲区头,类似于文件系统引导块

2024-02-24_10-56

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct buffer_head {
char * b_data; /* pointer to data block (1024 bytes),指向数据块 */
unsigned long b_blocknr; /* block number,数据逻辑块号 */
unsigned short b_dev; /* device (0 = free),块设备号 */
unsigned char b_uptodate; /* 是否更新 */
unsigned char b_dirt; /* 0-clean,1-dirty,是否被占用 */
unsigned char b_count; /* users using this block */
unsigned char b_lock; /* 0 - ok, 1 -locked,是否锁定 */
struct task_struct * b_wait;
struct buffer_head * b_prev;
struct buffer_head * b_next;
struct buffer_head * b_prev_free; /* 空闲缓冲区由循环双向链表构成 */
struct buffer_head * b_next_free;
};

2024-02-24_15-44

因此,对于块设备而言我们想要获得块,所需要做的是:

  • 分配一个buffer_head
  • 设置该buffer指向空闲缓冲区的一个有效buffer
    • 如果找到的话需要等待解锁
    • 解锁后还需要判断找到的块是否是要申请的块,因为有可能在等待解锁期间块的信息已经被改变了
      • 块如果被改变了则返回到第一步
    • 判断该块是否脏了,即是否有残余数据,有残余数据的话就回写
  • 从空闲缓冲区链表中出链,设置对应的结构体信息如块设备号,逻辑块号等
  • 写入所需数据
  • 算出该buffer_head的hash值,填入散列数组对应的链表的尾部

需要注意的是,在第二步等待系统内核分配数据块时,内核在申请前后加了锁,这是为了防止其他线程再次申请内存块

无论读取什么资源,都是先getblk(获取资源对应设备和块号的高速缓冲区),然后再bread(确认数据是否为最新的),最后进行区域内存的拷贝,从bh的b_data区域拷贝到要用到数据的内存中

bio

在2.6内核后,buffer_head的功能逐渐弱化为映射内存和磁盘关系的结构体,大部分的操作函数主要在bio结构体中实现

2024-03-02_14-33

bio里重要的结构体:

  • bi_io_vec
    • bi_io_vec指向bio_vec结构体链表表头,链表内包含了各页面的指针
  • bi_idx
    • bi_idx指向指向bio_vec结构体链表当前的节点,这可以找到当前io操作的位置
  • bi_vcnt
    • bi_vcnt记录了bio结构体的使用计数,当为0时就需要回收这个bio结构体

2024-03-02_14-38

2024-03-02_14-39

相比buffer_head,bio有如下优点:

  • buffer_head处理的是page指针,但是bio处理的是page指针数组,因此bio有大块内存的处理能力,这在高端内存中很有用
  • bio适用于先分散后集中的IO操作,操作的数据可以来自于多个不相邻的物理页面
  • bio时更轻量级的结构体,因为它只需要存储IO操作的信息就行了

inode

Linux 文件系统会为每个文件分配两个数据结构:索引节点(index node)和目录项(directory entry),它们主要用来记录文件的元信息和目录层次结构

  • 索引节点,也就是 inode,用来记录文件的元信息,比如 inode 编号、文件大小、访问权限、创建时间、修改时间、数据在磁盘的位置等等。索引节点是文件的唯一标识,它们之间一一对应,也同样都会被存储在硬盘中,所以索引节点同样占用磁盘空间
  • 目录项,也就是 dentry,用来记录文件的名字、索引节点指针以及与其他目录项的层级关联关系。多个目录项关联起来,就会形成目录结构,例如/mnt/cdrom/music中,\,mnt,cdrom,music均为目录项。但它与索引节点不同的是,目录项是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存。VFS在执行目录操作时会现场创建目录项。
    扇区:块设备上一个长度为512B的数据块,在不同文件系统中,扇区和盘块的对应关系是不同的,有的文件系统是4个扇区对应一个盘块,有的是8个

mount:把文件系统的超级块读到高速缓冲区中,并且放到超级块数组中

从磁盘中读写信息,数据,inode的流程

  • 找到指定dev,并根据dev找到超级块
  • 通过超级块中的信息计算要读的逻辑块号,并将这个块放入高速缓冲区
    • 读:读取块中的b_data,读完之后释放高速缓冲区
    • 写:将要写入的数据写入b_data,写完之后设置dirty标志位,等待sys_sync系统调用统一写入块设备,最后释放高速缓冲区

硬链接:只要链接数不为0,即使删除源文件,内容也不会消失

删除文件就是把inode的link数改为0,
inode中有dev和block,根据这两个信息就可以把硬盘载入高速缓冲区

从上面代码中可以看出,内核从哈希表中搜索现成的缓冲块时,只看设备号和块号,其他的什么都不管。只要缓冲块与硬盘数据块的绑定关系还存在,就认定数据块中的数据仍然停留在缓冲块中,就可以直接用,不需要从硬盘上读取,节省了100倍的硬盘读取时间

2024-02-26_14-23

2024-02-26_16-33

2024-02-26_16-32

IO调度算法

由于APP对读写要求的不同:对读操作时间敏感,对写操作则不然,并且主流存储设备为磁盘时还需要优化寻道时间,因此产生了如下算法

适用于机械硬盘的算法

Linus电梯算法是最早的适用于机械硬盘的算法,这种算法在接受IO请求时会将此次的IO与尚未执行的请求合并,这样多次请求就可以转化为一次请求。如果合并失败,算法会尝试将此次请求按磁盘扇区的排列顺序插入到请求队列中,这也是Linus电梯算法适合机械硬盘设备的原因。这种算法与电梯调度类似,因此得名为Linus电梯算法,同时也是下面几种机械硬盘IO调度算法的基础

最终期限IO调度算法(deadline)

Linus电梯算法不能解决请求数太多时对其他请求的饥饿问题,deadline算法在Linus电梯的排序队列基础上又引入了读写双队列来保证新请求的响应性,对于读操作来说,当超过500ms未响应就会强制执行读操作,即从读队列中获取操作,写操作则为5s

预测IO调度算法(anticipatory)

deadline算法在解决了请求的饥饿问题时损失了系统吞吐量,因为当响应读操作磁头移动的过程中不能进行写操作。为了解决这一问题,预测算法在响应操作时会等待几ms,这样当有在新请求的扇区附近有请求时会一并执行(相当于在deadline里套了个Linus电梯)

完全公平队列IO调度算法(cfq)

这种算法与上列算法截然不同,它是按照进程来对IO操作分类的,比如music进程下有4个IO操作,movie有8个IO操作,那么这种算法会使用时间片轮流对IO操作进行调度,保证进程的公平,这种算法主要用于桌面操作,但是如果没有特殊情况,在其他情况下工作的也很好

适用于固态硬盘的算法

空操作的IO调度算法(noop)

这种算法除了合并IO操作外什么也不做,因为固态硬盘不需要寻道操作

Linux内核内存管理

内核空间不分页

linux内存布局
使用命令行生成内存布局

2024-03-04_22-19

2024-03-04_22-42

vmalloc是在虚拟内存中申请一块连续地址,kmalloc则是在物理内存中申请一块连续地址。内核代码主要使用kmalloc,这是因为当物理地址不连续时需要为每个独立的物理地址项建立页表项,这会损失性能

内核空间栈小且固定(现在应该支持可变大小了?),用户空间栈大且可变

中断程序的栈与内核栈分开,这样哪怕在编译时给定内核栈为1页也不会爆栈了,并且也不需要每次分给多页内存给内核栈,这样在机器长时间运行后的分配内存的压力也会变小

内存碎片

当需要1字节而需要一整块页表时,剩余的4095字节被称为内部碎片,当内存空间被各种进程分割为大大小小的区域时产生的碎片被称为内部碎片,可以通过内存紧缩等算法减少内部碎片

伙伴算法

伙伴算法(buddy system)是为了管理大块的内存页从而减少外部碎片的一种算法,首先内核将内存分为11个链表,每个链表所管理的内存大小固定,大小均为4K*2^n倍,例如0号链表管理的内存为4K,1号链表为8K,当需要申请内存时需要从相应的链表中获得内存,比如需要12K的内存则需要从2号链表中获取,这样就可以统一管理大块内存

slab

slab是为了管理小块的内存从而减少内部碎片的一种算法,首先内核将所有高速缓存分为大小不同的组,每一组有若干块高速缓存并存储相同的对象,每一块高速缓存下有若干slab块并有三条slab链表,分别是空闲的slab链表,被占用的slab链表,被占满的slab链表,每个slab块都有若干页并且管理相同的数据结构,比如某块slab专门用于管理inode,或者专门管理task_struct,因此需要这种类似结构的内存时,只需要获取对应slab就可以了,而不必需要新申请一个页

因此使用slab会有很多好处:

  • 适应了小块内存的分配,解决了伙伴算法不能管理小块内存并产生大量内部碎片的缺点
  • 将频繁使用的对象缓存起来,减少分配回收时的开销

对于小型嵌入式系统来说,存在一个slab模拟层,名字叫做slob,但会有碎片问题

RCU

RCU(Read-Copy Update),读时任意读,写时先复制一份副本,然后在副本上更新,最后再一次性的替换旧数据

RCU支持对于某一个数据读写同时操作,这是因为RCU本质上是某一个数据copy一个副本,写进程写副本,读进程读原来的数据。当写完数据时,写进程会检查原数据结构是否被读进程占用,如果被占用就等待读进程完成操作,然后互斥的将副本拷贝进原数据

RCU通常被用于链表上的数据操作,伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct devmem{
struct list_head list;
int flag;
int length;
int data;
}

LIST_HEAD(list);

/* qlist给读进程,qlist给写进程 */
plist = search(list, data);
qlist = kmalloc(sizeof(devmem));

/* 拷贝数据 */
*qlist = *plist;

/* 写数据 */
qlist->flag = 1;
qlist->length = 2;
qlist->data = 3;

/* 等待读进程完成操作 */
synchronize_rcu();

/* 拷贝到原数据 */
list_replace_rcu(plist, qlist);

kfree(qlist);

堆内存管理

mmap与malloc都会调用到brk系统调用(实际上malloc调用的堆空间大于128k时会自动调用mmap),内核维护着start_brk与brk两个指针,分别指向堆空间起始和终止地址,申请内存本质上是通过改变brk的边界来扩展内存的

为了保证内存管理的高效性,linux在glibc,标准IO(fread/fwrite)分别设置了两层缓冲,glibc内部有个内存分配器,标准IO内部又有缓冲区,经过这两层后之后才会进入内核的缓冲区

匿名内存

映射在内存中的文件具有相应的路径和名称,这就是普通的文件映射。而对于堆栈,mmap这种内存来说并没有对应的文件而只有对应的进程,我们称后者这些内存为匿名内存。

Linux内核数据结构

算法复杂度

O(1)并不一定优于O(n),例如当执行某些算法时,时间复杂度O(1)的程序无论输入如何总是需要3小时,但这个程序输入很小的情况下O(n)明显更合适

二叉树是一种输入结构

二叉平衡树

红黑树

红黑树适用于对特定节点进行搜索,链表适合对节点进行遍历

Linux内核进程调度

内核抢占:在内核态也可以被中断调度出去执行更高优先级的代码的特性

Linux对进程或线程不做结构上的区分,他们的结构体是完全一样的,只是线程可以进行资源的共享

Linux采用了完全公平算法(CFS)
普通的调度算法会根据nice值(-20~+19,nice值越大,占用的时间片越少,默认nice值为0)来分配时间片,但只这会造成以下问题:
* 随着任务数的增多,时间片会接近0,系统始终处于调度当中
* 当进程优先级分配不合理时,进程切换会频繁。比如同样是两个进程,如果nice都是0的话会轮流调度,对于5ms的时间片来说它们每5ms就会调度一次,而对于nice值为20和0的进程来说,他们调度的总时长是(20+1)*5=105ms,两者每105ms会调度一次
* nice值是相对的,则会导致进程调度的效果不同。比如nice为20和19的进程,这两个进程时间片分别是100ms和105ms,但是对于nice值为0和1的进程来说,时间片分别是10ms和5ms,两者占据时间片的百分比差距很大

CFS的解决办法:

  • 规定时间片的最小粒度为1ms
  • 时间片不与nice绝对值挂钩,而与相对值挂钩

但是当任务数过大时(比如几千个),CFS并不是公平的,随着任务数的增多,nice的影响会越来越小,使得每个任务运行时间都最终趋近于1ms

CFS采用了计算vruntime的方法来保证公平调度,vruntime是一个加权后的时间,当需要调度时,CFS会找出vruntime最小的那个进程或线程进行调度

为了管理众多进程,CFS采用红黑树的数据结构,这样可以使搜索时间显著下降

内存屏障:由于CPU执行与编译器编译后的结果是乱序的,为了保证代码按正确的顺序的运行,厂家提供了内存屏障指令,在内存屏障的上下文代码按一定顺序执行,如下文,在线程2,先执行c=b;而后执行d=a;,如果没有内存屏障代码的话假如线程1与线程2被执行时同时颠倒顺序会导致出现预料外的结果

2024-03-01_11-20

为了保证检索定时器的效率,Linux将定时器分为五组,当超过规定时间时定时器将随组一起下移,这保证了搜索效率

不负责的程序员经常使用get_cpu()等函数来保证数据与CPU绑定(每个CPU都有一段专属的内存区域),虽然这对防止竟态有重要作用(唯一需要注意的内核抢占问题也在get_cpu()中自动实现了),但是这也会造成一核有难多核围观的后果

:::alert-info
即使不了解Linux代码风格也可以使用官方的indent工具来格式化代码啦
:::

读写锁

读写锁支持多个进程同时读数据,但只能有一个进程写数据。由于读读不互斥,读写互斥(读到写到一半的脏数据),写写互斥,因此需要读锁来保证读写互斥,写锁保证写写互斥

:::alert-danger
务必注意return的时候是否解锁,如果程序中有多处return那么可以考虑使用goto来统一跳转解决
:::