嵌入式Linux系统编程-》同步互斥机制+面试题+源码【第7天】

活动地址:毕业季·进击的技术er

1. 基本概念

互斥与同步是最基本的逻辑概念:


互斥指的是: 控制两个进度使之互相排斥,不同时运行。
同步指的是: 控制两个进度使之有先有后,次序可控。


在这里插入图片描述

2. 互斥锁

2.1 基本逻辑

使得多线程间互斥运行的最简单办法,就是增加一个互斥锁。任何一条线成要开始运行互斥区间的代码,都必须先获取互斥锁,而互斥锁的本质是一个二值信号量,因此当其中一条线程抢先获取了互斥锁之后,其余线程就无法再次获取了,效果相当于给相关的资源加了把锁,直到使用者主动解锁,其余线程方可有机会获取这把锁。

2.2 函数接口

定义 互斥锁是一个特殊的变量,定义如下:

#include <pthread>
pthread_mutex_t m;

一般而言,由于互斥锁需要被多条线程使用,因此一般会将互斥锁定义为全局变量。

初始化与销毁

未经初始化的互斥锁是无法使用的,初始化互斥锁有两种办法:

静态初始化
动态初始化

静态初始化很简单,就是在定义同时赋予其初值:

pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;

由于静态初始化互斥锁不涉及动态内存,因此无需显式释放互斥锁资源,互斥锁将会伴随程序一直存在,直到程序退出为止。而所谓动态初始化指的是使用 pthread_mutex_init()
给互斥锁分配动态内存并赋予初始值,因此这种情形下的互斥锁需要在用完之后显式地进行释放资源,接口如下:

#include <pthread.h>

// 初始化互斥锁
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
                       const pthread_mutexattr_t *restrict attr);

// 销毁互斥锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);

接口说明:

`mutex:互斥锁
attr:互斥锁属性(一般设置为NULL)`

加锁与解锁

互斥锁的基本操作就是加锁与解锁,接口如下:

#include <pthread.h>
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;

// 加锁
pthread_mutex_lock( &m );

// 解锁
pthread_mutex_unlock( &m );

示例代码

将此前判断偶数的代码用互斥锁加以改进如下:

// concurrency.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>
#include <string.h>
#include <pthread.h>

pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;

int global = 100;

void *isPrime(void *arg)
{
    
    
    while(1)
    {
    
    
        pthread_mutex_lock(&m);

        // 一段朴素的代码
        if(global%2 == 0)
            printf("%d是偶数\n", global);

        pthread_mutex_unlock(&m);
    }
}

int main()
{
    
    
    pthread_t tid;
    pthread_create(&tid, NULL, isPrime, NULL);

    // 一条人畜无害的赋值语句
    while(1)
    {
    
    
        pthread_mutex_lock(&m);
        global = rand() % 5000;
        pthread_mutex_unlock(&m);
    }
}

运行结果如下:

gec@ubuntu:~$ ./concurrency
492是偶数
2362是偶数
2778是偶数
3926是偶数
540是偶数
3426是偶数
4172是偶数
112是偶数
368是偶数
2576是偶数
1530是偶数
1530是偶数
2862是偶数
4706是偶数
...
gec@ubuntu:~$

可见,有了互斥锁之后,输出的结果正确了。

3. 读写锁

3.1 基本逻辑

对于互斥锁而言,凡是涉及临界资源的访问一律加锁,这在并发读操作的场景下会大量浪费时间。要想提高访问效率,就必须要将对资源的读写操作加以区分:读操作可以多任务并发执行,只有写操作才进行恰当的互斥。这就是读写锁的设计来源。
在这里插入图片描述

读写锁提高了资源访问的效率


定义 与互斥锁类似,读写锁也是一种特殊的变量:

pthread_rwlock_t rw;

初始化

与互斥锁类似,读写锁也分成静态初始化和动态初始化:

#include <pthread.h>

// 静态初始化:
pthread_rwlock_t rw = PTHREAD_RWLOCK_INITIALIZER;

// 动态初始化与销毁:
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
                        const pthread_rwlockattr_t *restrict attr);

int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

加锁

读写锁最大的特点是对即将要做的读写操作做了区分:

读操作可以共享,因此多条线程可以对同一个读写锁加多重读锁
写操作天然互斥,因此多条线程只能有一个拥有写锁。(注意写锁与读锁也是互斥的)

#include <pthread.h>

// 读锁
// 1,阻塞版本
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
// 2,非阻塞版本
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);

// 写锁
// 1,阻塞版本
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
// 2,非阻塞版本
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

操作原则:

如果只对数据进行读操作,那么就加 → 读锁。 
如果要对数据进行写操作,那么就加 → 写锁。

解锁

#include <pthread.h>

int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

4. POSIX信号量基本概念

POSIX信号量与IPC信号量组中的信号量元素的逻辑完全一样,但POSIX信号量操作更加简便,接口更加易用。在多进程多线程中运用广泛。

POSIX信号量分成两种:

POSIX匿名信号量

通常用在线程间 只存在于内存,在文件系统中不可见

POSIX具名信号量

通常用在进程间 存在于文件系统/dev/shm中,可被不同进程操作

5. POSIX匿名信号量

5.1 定义

#include <semaphore.h>
sem_t s;

5.2 初始化

#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);

接口说明:

sem:待初始化信号量指针
pshared:指定信号量的作用范围
`0:作用于进程内的线程间
非0:作用于进程间`
value:信号量的初始值

示例

sem_t s;

int main()
{
    
    
  // 初始化POSIX匿名信号量:
  // 将其设定为在本进程内的个线程间使用
  // 并将其初始值设定为1
  sem_init(&s, 0, 1);
}

5.3 P/V 操作

POSIX信号量(不管是匿名的还是具名的)的 P/V 操作相对于systemV的信号量组而言,接口非常简单:

#include <semaphore.h>

int sem_wait(sem_t *sem); // P操作
int sem_post(sem_t *sem); // V操作

接口说明:

P操作即申请资源,因此 sem_wait()在资源不足时会阻塞,而V操作永远不会阻塞。

示例

#include <semaphore.h>

sem_t s;
char buf[100];

void *routine(void *arg)
{
    
    
    while(1)
    {
    
    
        // 申请信号量-1,输出字符串的长度
        sem_wait(&s);
        printf("%d\n", strlen(buf));
    }
}

int main()
{
    
    
    // 初始化,信号量初始值为0
    sem_init(&s, 0, 0);
    
    pthread_t t;
    pthread_create(&t, NULL, routine, NULL);

    while(1)
    {
    
    
        fgets(buf, 100, stdin);

        // 输入字符串后,释放信号量+1
        sem_post(&s);
    }
}

6. POSIX具名信号量

POSIX 具名信号量主要用在多进程间同步互斥,其 P/V 操作与匿名版本无异,其最大的特点是存在于文件系统 /dev/shm中,可以被系统中任意有权限的进程打开。

6.1 创建和打开

POSIX 具名信号量使用如下接口创建:

#include <fcntl.h>           /* For O_* constants */
#include <sys/stat.h>        /* For mode constants */
#include <semaphore.h>

sem_t *sem_open(const char *name, int oflag);
sem_t *sem_open(const char *name, int oflag, mode_t mode, unsigned int value);

说明

与文件IO函数 open() 类似,sem_open() 也提供了两个版本,初建时需要指定文件权限 mode 和初始值value,后续再打开使用时则无需指定。

示例

// 在进程 a.c 中创建具名信号量
int main()
{
    
    
    // 创建有名信号量,参数1代表初始值为1
    sem_t *s = sem_open("mysem", O_CREAT, 0666, 1);
}
// 在进程 b.c 中打开具名信号量
int main()
{
    
    
    // 打开具名信号量
    sem_t *s = sem_open("mysem", O_RDWR);
}

编译程序 a.c(注意要加线程库 -lpthread)并运行该程序,便会在系统 /dev/shm/ 产生对应的文件:

gec@ubuntu:~$ gcc a.c -o a -lpthread
gec@ubuntu:~$ ./a
gec@ubuntu:~$ ls -l /dev/shm/
总用量 4
-rw-rw-r-- 1 gec gec 32 Nov 26 18:27 sem.mysem
gec@ubuntu:~$

6.2 P/V 操作

与匿名信号量完全一致:

#include <semaphore.h>

int sem_wait(sem_t *sem); // P操作
int sem_post(sem_t *sem); // V操作

示例

// a.c
int main()
{
    
    
    sem_t *s = sem_open("mysem", O_CREAT, 0666, 0);

    printf("A\n");
    sem_post(s); // V操作
}
// b.c
int main()
{
    
    
    sem_t *s = sem_open("mysem", O_RDWR);

    // 等待A执行完毕,才执行B
    sem_wait(s); // P操作
    printf("B\n");
}

信号量的V操作就像一个远程开关,控制另一个进程的进度:在进程A尚未执行 sem_post(s) 之前,进程B会一直在 sem_wait(s)静静等待,这就是多进程进度控制。


注意,上述程序 a.c 和程序 b.c 是两个独立的程序,彼此之间没有任何共享的变量和数据,他们通过基于文件系统的具名信号量达成协同。

6.3 关闭、删除和其他注意事项

POSIX 具名信号量跟文件操作非常类似,打开之后会在内核需要对其维护,因此在不再需要的时候应该予以关闭:

sem_close(s);

另外,即使所有进程都关闭了信号量并且退出,具名信号量对应的文件是不会消失的,并且会保留所有 P/V操作后的值,如果不再需要这个文件本身,则除了可以直接在文件系统中删除外,也可以使用如下接口删除:

sem_unlink("mysem");

上文提到,具名信号量会将所有的 P/V 操作后的值,因此向上述程序 a.c 如果连续执行三遍,那么信号量的值将会被 +3 ,因此程序 b.c
可以连续进行三次 P 操作。


7. 条件量基本概念

在许多场合中,程序的执行通常需要满足一定的条件,条件不成熟的时候,任务应该进入睡眠阻塞等待,条件成熟时应该可以被快速唤醒。另外,在并发程序中,会其他任务同时访问该条件,因此任何时候都必须以互斥的方式对条件进行访问。条件量就是专门解决上述场景的逻辑机制。


注意,上述表述中,条件和条件量是两个不同的东西,所谓条件就是指程序要继续运行所需要的前提条件,比如文件是否读完、内存是否清空等具体的场景限定,而条件量(即pthread_cond_t)是本节课件要讨论的一种同步互斥变量,专用于解决上述逻辑场景。

在这里插入图片描述
说明:

在进行条件判断前,先加锁(防止其他任务并发访问) 成功加锁后,判断条件是否允许 若条件允许,则直接操作临界资源,然后释放锁
若条件不允许,则进入条件量的等待队列中睡眠,并同时释放锁
在条件量中睡眠的任务,可以被其他任务唤醒,唤醒时重新判定条件是否允许程序继续执行,当然也是必须先加锁。


8. 条件量的使用

条件量一般要跟互斥锁(或二值信号量)配套使用,互斥锁提供锁住临界资源的功能,条件量提供阻塞睡眠和唤醒的功能。

一般流程示例

以取款为例,假设有多个任务可同时访问存款余额 balance,其中某个任务希望从中取出 ¥100 元,并且要求满足如下逻辑:

如果余额中有大于等于100元,则立即取出
如果余额小于100元,则进入睡眠等待
当有别的任务修改了余额时可被唤醒,并继续判定是否可取款
pthread_mutex_t m;  // 互斥锁
pthread_cond_t  v;  // 条件量
// 银行余额(全局变量,意味着有别的进程可随时访问)
extern int balance;

int main()
{
    
    
    // 1,初始化
    pthread_mutex_init(&m, NULL);
    pthread_cond_init(&v, NULL);

    // 2,对m加锁
    pthread_mutex_lock(&m);

    // 2,当条件不允许时,进入条件量中睡眠
    //    进入睡眠时,会自动对m解锁
    //    退出睡眠时,会自动对m加锁
    while(balance < 100)
        pthread_cond_wait(&v, &m);

    // 3,取款
    balance -= 100;

    // 4,对m解锁
    pthread_mutex_unlock(&m);
}

其他任务,可以在适当的时候,通过如下接口来唤醒处于睡眠态的任务:

// 单个唤醒,唤醒第一个进入条件量中睡眠的任务
pthread_cond_signal(&v);

// 集体唤醒,唤醒进入条件量中睡眠的所有任务
pthread_cond_broadcast(&v);

在这里插入图片描述


9. 死锁概念

死锁指的是由于某种逻辑问题,导致等待一把永远无法获得的锁的困境。比如最简单的是同一线程,连续对同一锁资源进行加锁,就进入了死锁。

在这里插入图片描述

死锁

最简单的死锁示例

pthread_mutex_t m;

int main()
{
    
    
    pthread_mutex_init(&m, NULL);

    // 正常加锁
    pthread_mutex_lock(&m);

    // 未释放锁前重复加锁,进入死锁状态
    pthread_mutex_lock(&m);

    // 下面的代码永远无法执行
    ...
    ...
}

以上死锁的例子,可以通过仔细检查代码得以避免,但在现实场景中,有些产生死锁的情况是无法避免的,比如如下情形:


一条线程持有一把锁,期间不能屏蔽取消指令 然后又恰巧被取消指令强制终止,此时死锁的产生变得不可避免。

产生死锁示例

void *routine(void *arg)
{
    
    
	thread_pool *pool = (thread_pool *)arg;
	struct task *p;

	while(1)
	{
    
    
        // 操作临界资源之前,加锁
		pthread_mutex_lock(&pool->lock);

        // 条件不允许时,进入条件量等待
		while(pool->waiting_tasks == 0 && !pool->shutdown)
			pthread_cond_wait(&pool->cond, &pool->lock);

        // 条件允许时,操作临界资源
		p = pool->task_list->next;
		pool->task_list->next = p->next;
		pool->waiting_tasks--;

        // !!! 注意 !!!
        // 线程若恰好在此处被意外终止,将导致死锁

        // 解锁
		pthread_mutex_unlock(&pool->lock);

        // 其他操作
		pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
		(p->do_task)(p->arg);
		pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);

		free(p);
	}

	pthread_exit(NULL);
}

上述代码中,若线程在中间被取消,则导致死锁。对于这种情况,一个可行的解决办法是:

提前准备一个解锁处理函数,并将其压入线程专用的函数栈中备用。 准备操作临界资源,加锁 操作临界资源

重点:

若线程在此期间意外终止,则会自动调用处理函数解锁 解锁 在函数栈中弹出处理函数。

说明:

上述做法实际上相当于现实生活中的立遗嘱,因为人去世之后是无法再做任何事情的,因此为了防止死亡在关键阶段意外到来,可以在提前立遗嘱,万一不幸遇到该情况就有了预案(处理函数),但如果并未发生此种情形,那么就将遗嘱作废(弹出处理函数且不执行)即可。


根据以上思路,可将上述代码改良为如下代码:

// 意外处理函数:
// 自动解锁
void handler(void *arg)
{
    
    
	pthread_mutex_unlock((pthread_mutex_t *)arg);
}

void *routine(void *arg)
{
    
    
	thread_pool *pool = (thread_pool *)arg;
	struct task *p;

	while(1)
	{
    
    
		/*
		** push a cleanup functon handler(), make sure that
		** the calling thread will release the mutex properly
		** even if it is cancelled during holding the mutex.
		**
		** NOTE:
		** pthread_cleanup_push() is a macro which includes a
		** loop in it, so if the specified field of codes that 
		** paired within pthread_cleanup_push() and pthread_
		** cleanup_pop() use 'break' may NOT break out of the
		** truely loop but break out of these two macros.
		** see line 56 below.
		*/
		//================================================//
		pthread_cleanup_push(handler, (void *)&pool->lock); // 提前准备好意外处理函数
		pthread_mutex_lock(&pool->lock);
		//================================================//

		// 1, no task, and is NOT shutting down, then wait
		while(pool->waiting_tasks == 0 && !pool->shutdown)
			pthread_cond_wait(&pool->cond, &pool->lock);

		// 2, no task, and is shutting down, then exit
		if(pool->waiting_tasks == 0 && pool->shutdown == true)
		{
    
    
			pthread_mutex_unlock(&pool->lock);
			pthread_exit(NULL); // CANNOT use 'break';
		}

		// 3, have some task, then consume it
		p = pool->task_list->next;
		pool->task_list->next = p->next;
		pool->waiting_tasks--;

		//================================================//
		pthread_mutex_unlock(&pool->lock); 
		pthread_cleanup_pop(0); // 弹出处理函数且不执行
		//================================================//

		pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
		(p->do_task)(p->arg);
		pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);

		free(p);
	}

	pthread_exit(NULL);
}

注意:

pthread_cleanup_push() 用于将处理函数填入栈中,在线程意外终止后会被自动调用。
pthread_cleanup_pop() 用于将栈中的处理函数弹出,若参数为0则意味着不执行,参数不为零则意味着执行该函数。
pthread_cleanup_push() 和 pthread_cleanup_pop() 必须成对出现。

点击下载完整示例代码:pthread_cleanup.c

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <unistd.h>
#include <signal.h>

#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>

#include <pthread.h>

pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;

void *count(void *arg)
{
    
    
	int i=1;
	while(1)
	{
    
    
		sleep(1);
		printf("sec: %d\n", i++);
	}
}

void handler(void *arg)
{
    
    
	printf("[%u] is cancelled.\n", (unsigned)pthread_self());
	pthread_mutex_t *pm = (pthread_mutex_t *)arg;

	pthread_mutex_unlock(pm);
}

void *routine(void *arg)
{
    
    
	#ifdef CLEANUP
	pthread_cleanup_push(handler, (void *)&m);
	#endif

	pthread_mutex_lock(&m);
	printf("[%u] lock the mutex!\n", (unsigned)pthread_self());

	/*
	** During sleep(), if the calling thread received a cancel-
	** request and HASN'T established any cleanup handlers to
	** unlock the mutex, it will leave the mutex a DEAD-LOCK
	** state.
	*/
	sleep(2);
	printf("[%u]: job finished!\n", (unsigned)pthread_self());


	pthread_mutex_unlock(&m);
	printf("[%u] unlock the mutex!\n", (unsigned)pthread_self());

	/*
	** NOTE: 
	**
	** pthread_cleanup_push() and pthread_cleanup_pop() may be
	** implemented as macro that expand to text containing '{'
	** and '}', respectively. For this reason, the caller must
	** user them pairly and ensure that they are paired within
	** a same function and at the same lexical nesting level.
	*/
	#ifdef CLEANUP
	pthread_cleanup_pop(0);
	#endif


	pthread_exit(NULL);
}

int main(int argc, char **argv)
{
    
    
	pthread_t t, t1, t2;
	pthread_create(&t, NULL, count, NULL);


	pthread_create(&t1, NULL, routine, NULL);
	pthread_create(&t2, NULL, routine, NULL);
	printf("[%u] ==> t1\n", (unsigned)t1);
	printf("[%u] ==> t2\n", (unsigned)t2);
	printf("[%u] ==> main\n", (unsigned)pthread_self());

	sleep(1);
	pthread_cancel(t1);
	pthread_cancel(t2);

	sleep(2);

	pthread_mutex_lock(&m);
	printf("[%u] locked the mutex!\n",
		(unsigned)pthread_self());
	pthread_mutex_unlock(&m);

	exit(0);
}


10.问题

1. 问:老师,POSIX信号量跟之前学的信号量组有什么区别?

答:
逻辑上是一样的,他们都是用来协调任务间同步和互斥的机制,也被译为信号灯,作用就跟控制十字路口的红绿灯一样,防止各个方向的汽车无序争抢道路资源而导致交通事故。
他们的主要区别在于,POSIX信号量(不管是匿名的还是具名的)都是单个定义和操作的,因此POSIX信号量只能协同单一的资源,但单一的资源进行
P/V 操作,而systemV的信号量组,可以同时对一组资源同时原子性地进行 P/V 操作。


11.面试题

编写一个多线程程序,让其中一条线程扮演父母(parent),其余线程扮演子女(chilren)。

父母和所有子女共有一个银行账号,可同时对余额存取款。
父母可随时往账户存入款项,并通过条件量通知子女。
子女可随时从账户取出款项,但必须保证账户数额正确、安全。
余额不可为负数。
取款数额最少是 ¥100 元。

参考代码

#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

// 银行余额
int balance = 0;

pthread_mutex_t m;
pthread_cond_t v;

// 辅助线程:显示程序流逝时间
void *count_times(void *args)
{
    
    
	int i = 1;
	while(1)
	{
    
    
		sleep(1);
		fprintf(stderr, "second: %d\n", i++);
	}

	pthread_exit(NULL);
}

void *routine(void *args)
{
    
    
	/* ====================== */
	pthread_mutex_lock(&m);
	/* ====================== */

	/*
	** NOTE: whenever pthread_cond_wait() returns, it
	** indicates that the calling thread is holding
	** the mutex. So we need to unlock the mutex.
	*/
	while(balance < 100)
		pthread_cond_wait(&v, &m);

	fprintf(stderr, "t%d: balance = %d\n", (int)args, balance);
	balance -= 100;

	/* ====================== */
	pthread_mutex_unlock(&m);
	/* ====================== */

    pthread_exit(NULL);
}

int main(int argc, char **argv)
{
    
    
	if(argc != 2)
	{
    
    
		printf("请指定子女线程数量\n");
        exit(0);
	}

	pthread_mutex_init(&m, NULL);
	pthread_cond_init(&v, NULL);

	pthread_t tid;
	pthread_create(&tid, NULL, count_times, NULL);


	/* create a set of threads */
	int i, thread_nums = atoi(argv[1]);
	for(i=0; i<thread_nums; i++)
		pthread_create(&tid, NULL, routine, (void *)i);

	/*
	** sleep(1) makes sure that the thread-routine
	** will lock the mutex first, then condition variable
	** cause the thread to be suspended.
	**
	** sleep(3) will indicate that pthread_cond_wait()
	** won't return even if the condition varialble has been
	** changed. Thus, the condition variable is NOT associated
	** with a specified condition.
	** 
	** Actually, pthread_cond_wait() calling threads will
	** be waken up ONLY IF someone calls pthread_cond_signal()
	** or pthread_cond_broadcast() and mutex is accessable.
	** therefore, pthread_cond_wait() calling thread should
	** test critical source again after pthread_cond_wait()
	** returned, because it may be changed by anther thread
	** which access the mutex first
	*/
	sleep(1);
	pthread_mutex_lock(&m);

	balance += 300;
	/*
	** pthread_cond_signal() will wake up the first thread in
	** the condition waiting-queue, while pthread_cond_broadcast()
	** wake up all threads which are waiting in the queue.
	**
	** NOTE: pthread_cond_signal() and pthread_cond_broadcast()
	** won't wake up the waiting thread immediately, it only
	** wake up one(or them) in the condition-variable waiting
	** queue.
	**
	** NOTE: POSIX doesn't require to own mutex before the calling
	** thread calls pthread_cond_signal() or pthread_cond_broadcast()
	*/

	pthread_cond_broadcast(&v);
	//pthread_cond_signal(&v);
	sleep(3);

	pthread_mutex_unlock(&m);

	pthread_exit(NULL);

	/*
	** exit() or return in main() will cause the whole thread group
	** terminate.
	*/
	// return 0;
}

活动地址:毕业季·进击的技术er

猜你喜欢

转载自blog.csdn.net/m0_45463480/article/details/125467579