1. 同步与互斥的概念
现代操作系统基本都是多任务操作系统,即同时有大量可调度实体在运行。在多任务操作系统中,同时运行的多个任务可能:
- 都需要访问/使用同一种资源;
- 多个任务之间有依赖关系,某个任务的运行依赖于另一个任务。
1.1. 同步
是指散步在不同任务之间的若干程序片断,它们的运行必须严格按照规定的某种先后次序来运行,这种先后次序依赖于要完成的特定的任务。最基本的场景就是:两个或两个以上的进程或线程在运行过程中协同步调,按预定的先后次序运行。比如 A 任务的运行依赖于 B 任务产生的数据。
1.2. 互斥
是指散步在不同任务之间的若干程序片断,当某个任务运行其中一个程序片段时,其它任务就不能运行它们之中的任一程序片段,只能等到该任务运行完这个程序片段后才可以运行。最基本的场景就是:一个公共资源同一时刻只能被一个进程或线程使用,多个进程或线程不能同时使用公共资源。
2. 竞态条件
当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。
-
竞态条件(Race Condition):计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。
-
临界区: 导致竞态条件发生的代码区称作临界区。
在临界区中使用适当的同步就可以避免竞态条件。临界区实现方法有两种,一种是用synchronized,一种是用Lock显式锁实现。
2.1. 实例
1 2 3 4 5 |
int count = 0; void add(int value) { count = count + value; } |
观察线程A和B交错执行add会发生什么,两个线程分别加了2和3到count变量上,两个线程执行结束后count变量的值应该等于5。然而由于两个线程是交叉执行的,两个线程从内存中读出的初始值都是0。然后各自加了2和3,并分别写回内存。最终的值并不是期望的5,而是最后写回内存的那个线程的值
上面例子中最后写回内存的是可能是线程A,也可能是线程B。如果没有采用合适的同步机制,线程间的交叉执行情况就无法预料。
- add()方法就是一个临界区,它会产生竞态条件。
2.2. 最常见竞态条件
- 先检测后执行
执行依赖于检测的结果,而检测结果依赖于多个线程的执行时序,而多个线程的执行时序通常情况下是不固定、不可判断的,从而导致执行结果出现各种问题。
- 锁
3. 互斥锁(同步)
在多任务操作系统中,同时运行的多个任务可能都需要使用同一种资源。这个过程有点类似于,公司部门里,我在使用着打印机打印东西的同时(还没有打印完),别人刚好也在此刻使用打印机打印东西,如果不做任何处理的话,打印出来的东西肯定是错乱的。
在线程里也有这么一把锁——互斥锁(mutex),互斥锁是一种简单的加锁的方法来控制对共享资源的访问,互斥锁只有两种状态,即上锁( lock )和解锁( unlock )。
3.1. 互斥锁使用场景
一次只能一个线程拥有互斥锁,其他线程只有等待。互斥锁是在抢锁失败的情况下主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒,互斥锁在加锁操作时涉及上下文的切换。
多个线程竞争同一资源,并且加锁的过程中对锁持有时间较长,或对性能需求不高的场景;
3.2. 互斥锁的特点
-
原子性:把一个互斥量锁定为一个原子操作,这意味着操作系统(或pthread函数库)保证了如果一个线程锁定了一个互斥量,没有其他线程在同一时间可以成功锁定这个互斥量;
-
唯一性:如果一个线程锁定了一个互斥量,在它解除锁定之前,没有其他线程可以锁定这个互斥量;
-
非繁忙等待:如果一个线程已经锁定了一个互斥量,第二个线程又试图去锁定这个互斥量,则第二个线程将被挂起(不占用任何cpu资源),直到第一个线程解除对这个互斥量的锁定为止,第二个线程则被唤醒并继续执行,同时锁定这个互斥量。
3.3. 互斥锁的操作流程
-
在访问共享资源后临界区域前,对互斥锁进行加锁;
-
在访问完成后释放互斥锁导上的锁。在访问完成后释放互斥锁导上的锁;
-
对互斥锁进行加锁后,任何其他试图再次对互斥锁加锁的线程将会被阻塞,直到锁被释放。对互斥锁进行加锁后,任何其他试图再次对互斥锁加锁的线程将会被阻塞,直到锁被释放。
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 |
#include <pthread.h> #include <time.h> // 初始化一个互斥锁。 int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr); // 对互斥锁上锁,若互斥锁已经上锁,则调用者一直阻塞, // 直到互斥锁解锁后再上锁。 int pthread_mutex_lock(pthread_mutex_t *mutex); // 调用该函数时,若互斥锁未加锁,则上锁,返回 0; // 若互斥锁已加锁,则函数直接返回失败,即 EBUSY。 int pthread_mutex_trylock(pthread_mutex_t *mutex); // 当线程试图获取一个已加锁的互斥量时,pthread_mutex_timedlock 互斥量 // 原语允许绑定线程阻塞时间。即非阻塞加锁互斥量。 int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex, const struct timespec *restrict abs_timeout); // 对指定的互斥锁解锁。 int pthread_mutex_unlock(pthread_mutex_t *mutex); // 销毁指定的一个互斥锁。互斥锁在使用完毕后, // 必须要对互斥锁进行销毁,以释放资源。 int pthread_mutex_destroy(pthread_mutex_t *mutex); |
3.4. Demo(阻塞模式)
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 49 50 51 |
//使用互斥量解决多线程抢占资源的问题 #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <pthread.h> #include <string.h> char* buf[5]; //字符指针数组 全局变量 int pos; //用于指定上面数组的下标 //1.定义互斥量 pthread_mutex_t mutex; void *task(void *p) { //3.使用互斥量进行加锁 pthread_mutex_lock(&mutex); buf[pos] = (char *)p; sleep(1); pos++; //4.使用互斥量进行解锁 pthread_mutex_unlock(&mutex); } int main(void) { //2.初始化互斥量, 默认属性 pthread_mutex_init(&mutex, NULL); //1.启动一个线程 向数组中存储内容 pthread_t tid, tid2; pthread_create(&tid, NULL, task, (void *)"zhangfei"); pthread_create(&tid2, NULL, task, (void *)"guanyu"); //2.主线程进程等待,并且打印最终的结果 pthread_join(tid, NULL); pthread_join(tid2, NULL); //5.销毁互斥量 pthread_mutex_destroy(&mutex); int i = 0; printf("字符指针数组中的内容是:"); for(i = 0; i < pos; ++i) { printf("%s ", buf[i]); } printf("\n"); return 0; } |
3.5. Demo(非阻塞模式)
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 |
#include <stdio.h> #include <pthread.h> #include <time.h> #include <string.h> int main (void) { int err; struct timespec tout; struct tm *tmp; char buf[64]; pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_lock (&lock); printf ("mutex is locked\n"); clock_gettime (CLOCK_REALTIME, &tout); tmp = localtime (&tout.tv_sec); strftime (buf, sizeof (buf), "%r", tmp); printf ("current time is %s\n", buf); tout.tv_sec += 10; err = pthread_mutex_timedlock (&lock, &tout); clock_gettime (CLOCK_REALTIME, &tout); tmp = localtime (&tout.tv_sec); strftime (buf, sizeof (buf), "%r", tmp); printf ("the time is now %s\n", buf); if (err == 0) printf ("mutex locked again\n"); else printf ("can`t lock mutex again:%s\n", strerror (err)); return 0; } |
4. 条件变量(同步)
与互斥锁不同,条件变量是用来等待而不是用来上锁的。条件变量用来自动阻塞一个线程,直 到某特殊情况发生为止。通常条件变量和互斥锁同时使用。
条件变量使我们可以睡眠等待某种条件出现。条件变量是利用线程间共享的全局变量进行同步 的一种机制,主要包括两个动作:
- 一个线程等待"条件变量的条件成立"而挂起;
- 另一个线程使 “条件成立”(给出条件成立信号)。
4.1. 条件变量原理及使用场景
条件的检测是在互斥锁的保护下进行的。线程在改变条件状态之前必须首先锁住互斥量。如果一个条件为假,一个线程自动阻塞,并释放等待状态改变的互斥锁。如果另一个线程改变了条件,它发信号给关联的条件变量,唤醒一个或多个等待它的线程,重新获得互斥锁,重新评价条件。如果两进程共享可读写的内存,条件变量 可以被用来实现这两进程间的线程同步。
- 场景:需要满足某种条件情况下才能解锁的场景;
4.2. 条件变量的操作流程如下
1. 初始化:init()或者pthread_cond_tcond=PTHREAD_COND_INITIALIER
;属性置为NULL;
2. 等待条件成立:pthread_wait,pthread_timewait.wait()释放锁,并阻塞等待条件变量为真 timewait()设置等待时间,仍未signal,返回ETIMEOUT(加锁保证只有一个线程wait);
3. 激活条件变量:pthread_cond_signal,pthread_cond_broadcast(激活所有等待线程)
4. 清除条件变量:destroy;无线程等待,否则返回EBUSY清除条件变量:destroy;无线程等待,否则返回EBUSY
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#include <pthread.h> // 初始化条件变量 int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr); // 阻塞等待 int pthread_cond_wait(pthread_cond_t *cond,pthread_mutex_t *mutex); // 超时等待 int pthread_cond_timewait(pthread_cond_t *cond,pthread_mutex *mutex, const timespec *abstime); // 解除所有线程的阻塞 int pthread_cond_destroy(pthread_cond_t *cond); // 至少唤醒一个等待该条件的线程 int pthread_cond_signal(pthread_cond_t *cond); // 唤醒等待该条件的所有线程 int pthread_cond_broadcast(pthread_cond_t *cond); |
4.3. 线程的条件变量实例1
Jack开着一辆出租车来到一个站点停车,看见没人就走了。过段时间,Susan来到站点准备乘车,但是没有来,于是就等着。过了一会Mike开着车来到了这个站点,Sunsan就上了Mike的车走了。如图所示:
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 49 50 51 |
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <pthread.h> pthread_cond_t taxicond = PTHREAD_COND_INITIALIZER; pthread_mutex_t taximutex = PTHREAD_MUTEX_INITIALIZER; void *traveler_arrive(void *name) { char *p = (char *)name; printf ("Travelr: %s need a taxi now!\n", p); // 加锁,把信号量加入队列,释放信号量 pthread_mutex_lock(&taximutex); pthread_cond_wait(&taxicond, &taximutex); pthread_mutex_unlock(&taximutex); printf ("traveler: %s now got a taxi!\n", p); pthread_exit(NULL); } void *taxi_arrive(void *name) { char *p = (char *)name; printf ("Taxi: %s arrives.\n", p); // 给线程或者条件发信号,一定要在改变条件状态后再给线程发信号 pthread_cond_signal(&taxicond); pthread_exit(NULL); } int main (int argc, char **argv) { char *name; pthread_t thread; pthread_attr_t threadattr; // 线程属性 pthread_attr_init(&threadattr); // 线程属性初始化 // 创建三个线程 name = "Jack"; pthread_create(&thread, &threadattr, taxi_arrive, (void *)name); sleep(1); name = "Susan"; pthread_create(&thread, &threadattr, traveler_arrive, (void *)name); sleep(1); name = "Mike"; pthread_create(&thread, &threadattr, taxi_arrive, (void *)name); sleep(1); return 0; } |
4.4. 线程的条件变量实例2
Jack开着一辆出租车来到一个站点停车,看见没人就等着。过段时间,Susan来到站点准备乘车看见了Jack的出租车,于是就上去了。过了一会Mike开着车来到了这个站点,看见没人救等着。如图所示:
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 49 50 51 52 53 54 55 56 57 58 59 |
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <pthread.h> int travelercount = 0; pthread_cond_t taxicond = PTHREAD_COND_INITIALIZER; pthread_mutex_t taximutex = PTHREAD_MUTEX_INITIALIZER; void *traveler_arrive(void *name) { char *p = (char *)name; pthread_mutex_lock(&taximutex); printf ("traveler: %s need a taxi now!\n", p); travelercount++; pthread_cond_wait(&taxicond, &taximutex); pthread_mutex_unlock(&taximutex); printf ("traveler: %s now got a taxi!\n", p); pthread_exit(NULL); } void *taxi_arrive(void *name) { char *p = (char *)name; printf ("Taxi: %s arrives.\n", p); for(;;) { if(travelercount) { pthread_cond_signal(&taxicond); travelercount--; break; } } pthread_exit(NULL); } int main (int argc, char **argv) { char *name; pthread_t thread; pthread_attr_t threadattr; pthread_attr_init(&threadattr); name = "Jack"; pthread_create(&thread, &threadattr, taxi_arrive, name); sleep(1); name = "Susan"; pthread_create(&thread, &threadattr, traveler_arrive, name); sleep(3); name = "Mike"; pthread_create(&thread, &threadattr, taxi_arrive, name); sleep(4); return 0; } |
4.5. 虚假唤醒(spurious wakeup)
虚假唤醒(spurious wakeup)在采用条件等待时,我们使用的是:
1 2 3 4 5 6 7 8 9 |
while(条件不满足) { condition_wait(cond, mutex); } // 而不是: if( 条件不满足 ) { Condition_wait(cond,mutex); } |
这是因为可能会存在虚假唤醒”spurious wakeup”的情况。
也就是说,即使没有线程调用condition_signal, 原先调用condition_wait的函数也可能会返回。此时线程被唤醒了,但是条件并不满足,这个时候如果不对条件进行检查而往下执行,就可能会导致后续的处理出现错误。
虚假唤醒在linux的多处理器系统中/在程序接收到信号时可能回发生。在Windows系统和JAVA虚拟机上也存在。在系统设计时应该可以避免虚假唤醒,但是这会影响条件变量的执行效率,而既然通过while循环就能避免虚假唤醒造成的错误,因此程序的逻辑就变成了while循环的情况。
5. 读写锁(同步)
读写锁与互斥量类似,不过读写锁允许更改的并行性,也叫共享互斥锁。互斥量要么是锁住状态,要么就是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁可以有3种状态:读模式下加锁状态、写模式加锁状态、不加锁状态。
一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁(允许多个线程读但只允许一个线程写)。
5.1. 读写锁使用场景
读写锁是特殊的自旋锁。特性是读共享,写互斥,默认是读优先,但是改成了写优先(即上写锁失败时,会拒绝后续所有的读锁)。
5.2. 读写锁的特点
- 如果有其它线程读数据,则允许其它线程执行读操作,但不允许写操作;
- 如果有其它线程写数据,则其它线程都不允许读、写操作。
5.3. 读写锁的规则
- 如果某线程申请了读锁,其它线程可以再申请读锁,但不能申请写锁;
- 如果某线程申请了写锁,其它线程不能申请读锁,也不能申请写锁。
读写锁适合于对数据结构的读次数比写次数多得多的情况。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#include <pthread.h> // 初始化读写锁 int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr); // 申请读锁 int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock ); // 申请写锁 int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock ); // 尝试以非阻塞的方式来在读写锁上获取写锁, // 如果有任何的读者或写者持有该锁,则立即失败返回。 int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock); // 解锁 int pthread_rwlock_unlock (pthread_rwlock_t *rwlock); // 销毁读写锁 int pthread_rwlock_destroy(pthread_rwlock_t *rwlock); |
5.4. Demo
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 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
// 一个使用读写锁来实现 4 个线程读写一段数据是实例。 // 在此示例程序中,共创建了 4 个线程, // 其中两个线程用来写入数据,两个线程用来读取数据 #include <stdio.h> #include <unistd.h> #include <pthread.h> pthread_rwlock_t rwlock; //读写锁 int num = 1; //读操作,其他线程允许读操作,却不允许写操作 void *fun1(void *arg) { while(1) { pthread_rwlock_rdlock(&rwlock); printf("read num first == %d\n", num); pthread_rwlock_unlock(&rwlock); sleep(1); } } //读操作,其他线程允许读操作,却不允许写操作 void *fun2(void *arg) { while(1) { pthread_rwlock_rdlock(&rwlock); printf("read num second == %d\n", num); pthread_rwlock_unlock(&rwlock); sleep(2); } } //写操作,其它线程都不允许读或写操作 void *fun3(void *arg) { while(1) { pthread_rwlock_wrlock(&rwlock); num++; printf("write thread first\n"); pthread_rwlock_unlock(&rwlock); sleep(2); } } //写操作,其它线程都不允许读或写操作 void *fun4(void *arg) { while(1) { pthread_rwlock_wrlock(&rwlock); num++; printf("write thread second\n"); pthread_rwlock_unlock(&rwlock); sleep(1); } } int main() { pthread_t ptd1, ptd2, ptd3, ptd4; pthread_rwlock_init(&rwlock, NULL);//初始化一个读写锁 //创建线程 pthread_create(&ptd1, NULL, fun1, NULL); pthread_create(&ptd2, NULL, fun2, NULL); pthread_create(&ptd3, NULL, fun3, NULL); pthread_create(&ptd4, NULL, fun4, NULL); //等待线程结束,回收其资源 pthread_join(ptd1, NULL); pthread_join(ptd2, NULL); pthread_join(ptd3, NULL); pthread_join(ptd4, NULL); pthread_rwlock_destroy(&rwlock);//销毁读写锁 return 0; } |
6. 自旋锁(同步)
-
自旋锁与互斥量功能一样,唯一一点不同的就是互斥量阻塞后休眠让出cpu,而自旋锁阻塞后不会让出cpu,会一直忙等待,直到得到锁。自旋锁在用户态的函数接口和互斥量一样,把pthread_mutex_xxx()中mutex换成spin,如:pthread_spin_init()。
-
在任何时刻同样只能有一个线程访问对象。但是当获取锁操作失败时,不会进入睡眠,而是会在原地自旋,直到锁被释放。这样节省了线程从睡眠状态到被唤醒期间的消耗,在锁时间短暂的环境下会极大的提高效率。但如果加锁时间过长,则会非常浪费CPU资源;
6.1. 自旋锁使用场景
自旋锁在用户态使用的比较少,在内核使用的比较多!自旋锁的使用场景:
锁的持有时间比较短,即加锁和解锁时间间隔少,同时对性能需求较高场景下使用,免去状态转换的消耗;
7. 信号量(同步与互斥)
信号量广泛用于进程或线程间的同步和互斥,信号量本质上是一个非负的整数计数器,它被用来控制对公共资源的访问。
编程时可根据操作信号量值的结果判断是否对公共资源具有访问的权限,当信号量值大于 0 时,则可以访问,否则将阻塞。PV 原语是对信号量的操作,一次 P 操作使信号量减1,一次 V 操作使信号量加1。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#include <semaphore.h> // 初始化信号量 int sem_init(sem_t *sem, int pshared, unsigned int value); // 信号量 P 操作(减 1) int sem_wait(sem_t *sem); // 以非阻塞的方式来对信号量进行减 1 操作 int sem_trywait(sem_t *sem); // 信号量 V 操作(加 1) int sem_post(sem_t *sem); // 获取信号量的值 int sem_getvalue(sem_t *sem, int *sval); // 销毁信号量 int sem_destroy(sem_t *sem); |
7.1. 信号量使用场景
用于线程间同步和互斥的,当一个线程完成操作后就通过信号量通知其它线程,然后别的线程就可以继续进行某些操作了。类似停车场,当容量超出限制,其他线程将阻塞等待。容量没超出限制,其他线程将可继续执行,并操作+1;
7.2. 信号量用于同步
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 |
// 信号量用于同步实例 #include <stdio.h> #include <unistd.h> #include <pthread.h> #include <semaphore.h> sem_t sem_g,sem_p; //定义两个信号量 char ch = 'a'; void *pthread_g(void *arg) //此线程改变字符ch的值 { while(1) { sem_wait(&sem_g); ch++; sleep(1); sem_post(&sem_p); } } void *pthread_p(void *arg) //此线程打印ch的值 { while(1) { sem_wait(&sem_p); printf("%c",ch); fflush(stdout); sem_post(&sem_g); } } int main(int argc, char *argv[]) { pthread_t tid1,tid2; sem_init(&sem_g, 0, 0); // 初始化信号量为0 sem_init(&sem_p, 0, 1); // 初始化信号量为1 // 创建两个线程 pthread_create(&tid1, NULL, pthread_g, NULL); pthread_create(&tid2, NULL, pthread_p, NULL); // 回收线程 pthread_join(tid1, NULL); pthread_join(tid2, NULL); return 0; } |
7.3. 信号量用于互斥
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 49 50 51 52 53 |
// 信号量用于互斥实例 #include <stdio.h> #include <pthread.h> #include <unistd.h> #include <semaphore.h> sem_t sem; //信号量 void printer(char *str) { sem_wait(&sem);//减一,p操作 while(*str) // 输出字符串(如果不用互斥,此处可能会被其他线程入侵) { putchar(*str); fflush(stdout); str++; sleep(1); } printf("\n"); sem_post(&sem);//加一,v操作 } void *thread_fun1(void *arg) { char *str1 = "hello"; printer(str1); } void *thread_fun2(void *arg) { char *str2 = "world"; printer(str2); } int main(void) { pthread_t tid1, tid2; sem_init(&sem, 0, 1); //初始化信号量,初始值为 1 //创建 2 个线程 pthread_create(&tid1, NULL, thread_fun1, NULL); pthread_create(&tid2, NULL, thread_fun2, NULL); //等待线程结束,回收其资源 pthread_join(tid1, NULL); pthread_join(tid2, NULL); sem_destroy(&sem); //销毁信号量 return 0; } |
转载自原文多线程的同步与互斥(互斥锁、条件变量、读写锁、自旋锁、信号量)
参考:
https://blog.csdn.net/lyl194458/article/details/90641870
https://blog.csdn.net/lianghe_work/article/details/47747413
https://blog.csdn.net/lianghe_work/article/details/47747497
https://blog.csdn.net/fulinus/article/details/39342371
https://blog.csdn.net/lianghe_work/article/details/47775637
https://www.cnblogs.com/wuchanming/p/4411908.html
https://blog.csdn.net/qq_29350001/article/details/71709326
https://blog.csdn.net/lianghe_work/article/details/47775741
微信赞赏
支付宝赞赏