Linux的同步和互斥机制-信号量 互斥锁 spinlock

前置知识

临界区:通常指某个代码片段,在该代码片段会访问共享资源,比如共享数据、共享硬件资源(打印机、IO)。串行使用共享资源,才能保证正确的输出结果,因此一个进程要等待另一个进程使用完后才能使用。

进程上下文和中断上下文:进程上下文包括:CPU所有寄存器中的值、进程的状态以及堆栈上的内容,当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态。中断对于进程来说是异步的,中断可以打断进程进入中断上下文,中断进入和退出时会保存恢复进程上下文。

同步:协调步调,进程按预定的先后次序运行。如A进程执行到一定程度时要依赖B进程的某个结果于是停下来;B继续执行将结果给A,A才再继续操作。

互斥:若系统中的访问共享资源,则这些进程和中断处于竞争状态。

并发的来源:

  • 系统是可抢占的,任何时候都有可能被抢走CPU
  • SMP系统,代码在多个处理器上运行
  • 设备中断导致的异步事件

获取锁冲突时2种处理方式:

  • 一个是原地等待
  • 一个是挂起当前进程,调度其他进程执行(睡眠)

编程设计时尽量减少资源共享

阻塞和非阻塞:

程序在等待调用结果(消息,返回值)时的状态,阻塞是指会挂起当前任务,非阻塞是指会定期查询状态。

信号量 semaphore

semaphore的基本结构

使用semaphore时,都是通过接口进行操作,不要访问成员。
lock:信号量基于自旋锁实现对资源的互斥管理。lock用来管理count和wait_list。
count:资源的个数。大于0,代表有剩余可用资源;等于0,代表资源忙,进程会进入睡眠并且等待其他进程释放资源。
wait_list:等待此资源的所有进程的队列。

/* 文件:include/linux/semaphore.h *//* Please don't access any members of this structure directly */
struct semaphore {
    raw_spinlock_t lock;
    unsigned int count;
    struct list_head wait_list;
};

常用操作

semaphore初始化

例如,定义全局变量g_sem,调用sema_init初始化为0,代表此时信号量不能获取。

struct semaphore g_sem;
void sema_init(struct semaphore *sem, int val);
DEFINE_SEMAPHORE(name);

获取信号量down

如果成员count大于0,则count减1后返回成功;
如果成员count等于0,则CPU调度到其他任务,等再次调度到本任务时检查是否应该退出for循环。
执行路径:down->_ _down->__down_common->schedule_timeout->schedule

/* 文件:kernel/locking/semaphore.c */
void down(struct semaphore *sem)
{
    unsigned long flags;

    raw_spin_lock_irqsave(&sem->lock, flags);
    if (likely(sem->count > 0))
        sem->count--;  /* count大于0代表有剩余资源,count减1后返回 */
    else
        __down(sem); /* count等于0代表资源忙,__down会调度其他任务 */
    raw_spin_unlock_irqrestore(&sem->lock, flags);
}

static inline int __sched __down_common(struct semaphore *sem, long state, long timeout)
{
    struct semaphore_waiter waiter;

    list_add_tail(&waiter.list, &sem->wait_list);
    waiter.task = current;
    waiter.up = false;

    for (;;) {
        if (signal_pending_state(state, current))
            goto interrupted;
        if (unlikely(timeout <= 0))
            goto timed_out;
        __set_current_state(state);
        raw_spin_unlock_irq(&sem->lock);
        timeout = schedule_timeout(timeout); /* 调度其他进程 */
        raw_spin_lock_irq(&sem->lock);
        if (waiter.up)  /* 其他进程会设置当前进程状态为ture,退出for循环 */
            return 0;
    }
 timed_out:
    list_del(&waiter.list);
    return -ETIME;

 interrupted:
    list_del(&waiter.list);
    return -EINTR;
}

down的不同版本

void down(struct semaphore *sem);
int __must_check down_interruptible(struct semaphore *sem);
int __must_check down_killable(struct semaphore *sem);
int __must_check down_trylock(struct semaphore *sem);
int __must_check down_timeout(struct semaphore *sem, long jiffies);

down会一直等待,不能被用户中断。
down_interruptible完成相同的工作,但是操作是可中断的,它允许等待某个信号量上的用户空间进程可被用户中断。如果操作被中断,该函数会返回非零值,而调用者不会拥有改信号量,因此必须检查返回。
down_trylock在调用时会立即返回是否获取到信号量的结果。
down_timeout是超时等待。

释放信号量up

信号量可以在任务或者中断上下文释放。
down和up可以不成对使用,在A进程调用up,在B进程调用down。
如果当前任务队列为空,则count++,直接返回;否则,唤醒队首任务。
执行路径:up->__up->wake_up_process

/* 文件:kernel/locking/semaphore.c */
void up(struct semaphore *sem)
{
    unsigned long flags;

    raw_spin_lock_irqsave(&sem->lock, flags);
    if (likely(list_empty(&sem->wait_list)))
        sem->count++; /* 如果当前任务队列为空,则count++,直接返回 */
    else
        __up(sem); /* 如果当前任务队列不为空,则唤醒队首任务 */
    raw_spin_unlock_irqrestore(&sem->lock, flags);
}
static noinline void __sched __up(struct semaphore *sem)
{
    struct semaphore_waiter *waiter = list_first_entry(&sem->wait_list,
                        struct semaphore_waiter, list); /* 获取队首的任务 */
    list_del(&waiter->list);
    waiter->up = true; /* 设置为true后,等待锁的进程会退出for循环 */
    wake_up_process(waiter->task); /* 调度指定任务 */
}
/* 文件:kernel/sched/core.c */
/**
 * wake_up_process - Wake up a specific process
 * @p: The process to be woken up.
 * Attempt to wake up the nominated process and move it to the set of runnable processes.
 * Return: 1 if the process was woken up, 0 if it was already running.
 * It may be assumed that this function implies a write memory barrier before
 * changing the task state if and only if any tasks are woken up.
 */
int wake_up_process(struct task_struct *p)
{
    return try_to_wake_up(p, TASK_NORMAL, 0, 1);
}

读写锁 rwsem

许多任务只会读取临界区数据而不修改内容,那么允许多个并发的读取者可以大大的提高性能。一个rwsem允许一个写入者或者无限多个读取者拥有该信号量。写入者具有更高的优先级,当某个给定写入者试图进入临界区时,在所有写入者完成工作之前,不允许读取者获取。如果有大量的写入者竞争该信号量,则这种实现会导致读取者饿死,即可能会长时间拒绝读取者的访问,因此rwsem适用于很少写访问且较多的读访问场景。reader/writer semaphore,读取者/写入者信号量。

rw_semaphore基本结构

/* if count is 0 then there are no active readers or writers
if count is +ve then that is the number of active readers
if count is -1 then there is one active writer
if wait_list is not empty, then there are processes waiting for the semaphore */

struct rw_semaphore {
  __s32 count;
  raw_spinlock_t wait_lock;
  struct list_head wait_list;
};

常用操作

初始化接口

init_rwsem(struct rw_semaphore *sem);

读接口

down_read是不可中断的休眠。down_read_trylock会立即返回,注意其用法与其他内核函数不同,其他函数在成功时返回零。由down_read获取的rwsem对象必须通过up_write释放。

void down_read(struct rw_semaphore *sem);
int down_read_trylock(struct rw_semaphore *sem);
up_read(struct rw_semaphore *sem);

写接口

down_write、down_write_trylock、up_write与读取者的对应函数行为相同。当某个快速修改完成时,我们可以使用downgrade_write来允许其他读取者的访问。

void down_write(struct rw_semaphore *sem);
void down_write_trylock(struct rw_semaphore *sem);
void up_write(struct rw_semaphore *sem);
void downgrade_write(struct rw_semaphore *sem);

同步 completion

completion信号量是一个轻量级的机制,它允许一个线程告诉另一个线程某个工作已经做完了。与semaphore区别是,

  • completion可以唤醒一个任务,也可以唤醒所有任务。semaphore没有此功能
  • semaphore可用于排他访问(可处理优先级反转),而completion仅用于同步操作不能用来处理排他访问。

completion结构体

done表示完成操作的次数。wait表示等待任务的队列

struct completion {
    unsigned int done;
    wait_queue_head_t wait;
};

常用操作

初始化

静态初始化:DECLARE_COMPLETION(struct completion *c)
动态初始化:init_completion(struct completion *c)

等待完成

void wait_for_completion(struct completion *);
unsigned long wait_for_completion_timeout(struct completion *x, unsigned long timeout);

完成

complete只会唤醒一个等待的任务,complete_all会唤醒所有等待任务。如果使用了complete_all,需要重新初始化该变量reinit_completion。
complete_all会把done变为最大值,而初始化时done为0。

void complete(struct completion *);
void complete_all(struct completion *);

互斥锁mutex

mutex基本结构

owner:atomic_long_t为一个原子型变量。
wait_lock:自旋锁,用于wait_list链表的保护操作。
wait_list:等待互斥锁的进程队列。

/* 文件:include/linux/mutex.h *//*
 * Simple, straightforward mutexes with strict semantics:
 *  使用用法:
 * - only one task can hold the mutex at a time
 * - only the owner can unlock the mutex
 * - multiple unlocks are not permitted
 * - recursive locking is not permitted
 * - a mutex object must be initialized via the API
 * - a mutex object must not be initialized via memset or copying
 * - task may not exit with mutex held
 * - memory areas where held locks reside must not be freed
 * - held mutexes must not be reinitialized
 * - mutexes may not be used in hardware or software interrupt
 *   contexts such as tasklets and timers
 */
struct mutex {
    atomic_long_t       owner;
    spinlock_t      wait_lock;
#ifdef CONFIG_MUTEX_SPIN_ON_OWNER
    struct optimistic_spin_queue osq; /* Spinner MCS lock */
#endif
    struct list_head    wait_list;
#ifdef CONFIG_DEBUG_MUTEXES
    void            *magic;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
    struct lockdep_map  dep_map;
#endif
};

常用操作

mutex初始化

定义全局变量g_mutex,调用mutex_init初始化。不允许对mutex进行重复初始化。

struct mutex g_mutex;
mutex_init(&g_mutex);

获取mutex

执行路径:mutex_lock->__mutex_lock_slowpath->__mutex_lock->__mutex_lock_common

释放mutex

执行路径:mutex_unlock->__mutex_unlock_slowpath->wake_up_q

自旋锁spinlock

什么是自旋锁

和信号量不同的是,如果自旋锁被其他人获得,则进入忙等待并重复检查这个锁,直到该锁可用为止,这就是自旋锁的自旋部分。自旋锁最初是为了在多核处理器系统中使用而设计的,可参考以下文的使用场景分析。

自旋锁的核心规则:

  • 任何拥有自旋锁的代码都必须是原子的,即不能休眠。当我们编写需要在自旋锁下执行的代码时,必须注意每一个所调用的函数。比如copy_from_user、kmalloc、copy_to_user。
  • 自旋锁必须在可能的最短时间内拥有。长的锁拥有时间将阻止系统调度,增加了内核的延迟。

spinlock使用场景

如果共享数据只在进程上下文访问,那么可以考虑使用semaphore、mutex、spinlock机制。但是如果在进程上下文和中断上下文都要访问共享数据,由于中断上下文中不能使用睡眠锁,这个时候就考虑使用spinlock。

进程上下文场景

获取spinlock锁时,需要禁止本地抢占。假设存在如下场景:

1)单核CPU,进程A、B都访问共享资源R。

进程A访问共享资源R的过程中发生了中断,中断唤醒了优先级更高的进程B,如果B访问共享资源R,那么由于A已获得锁,B获取获取不到锁,CPU进入永久的spin。因此在获取spinlock时要禁止本地抢占(preempt_disable)。

中断上下文场景

在中断上下文和进程上下文都访问spinlock时,需要禁止本地抢占禁止本地中断。假设存在如下场景:

1)进程A运行在CPU0访问共享资源R。

2)进程B运行在CPU1访问共享资源R。

3)中断服务函数也会访问共享资源R。

如果在CPU0上进程A持有spinlock进入临界区,这时中断发生调度到CPU1,看起来没什么问题,CPU0继续执行,CPU1等待CPU0释放锁。但是如果中断调度到CPU0,那么CPU0就会在中断永久spin,CPU1上的进程B也会由于获取不到锁而进入spin态。所有核都spin。为解决这个问题,禁止本地中断。

spinlock的基本结构

  spinlock简单调用raw_spinlock,raw_spinlock包含arch_spinlock_t系统结构相关的定义。早期spinlock只有一个整型变量用于标识锁是否已获取,在现代多核的cpu中,因为每个cpu都有cache,不需要去访问主存获取lock,所以持有lock的cpu释放锁后使其他cpu的cache都失效,当前cpu下一次获取锁更容易,导致出现了不公平。

  现在arch_spinlock_t结构体使用了ticket机制防止饥饿现象。可以用吃饭排队的情况去模拟cpu获取锁。next代表CPU调用spin_lock函数时获取的号码牌,每个CPU获取的号码牌要保证唯一,持有之后就等着叫号,因此next变量要进行原子操作;owner代表当前可以就餐的号码,每次释放锁要对owner+1,然后通知其他CPU去检查其持有的号码牌是否与owner相等,如果不相等就要继续等待。刚开始的时候next和owner的值都是0,当CPU0获取锁时持有号码0,将next++得1,CPU0判断持有号码0与owner相等进入临界区。由于CPU0吃饭较慢,这时CPU1、CPU2也过来了,CPU1调用spin_lock持有号码1,然后next++得2,CPU2调用spin_lock持有号码2,然后next++得3。CPU1和CPU2持有的号码分别是1和2,而当前owner是0,所以CPU1和CPU2都要等待通知。当CPU0吃完饭出来时将owner++得1,然后通知CPU1和CPU2去检查它们持有的号码。由于CPU1持有的号码是1与owner相等,所以CPU1进入临界区,而CPU2需要继续等到。这种ticket机制是排队机制,保证了公平性。

typedef struct spinlock {
    union {
        struct raw_spinlock rlock; /* raw_spinlock出现后,统一使用raw_spinlock */
#ifdef CONFIG_DEBUG_LOCK_ALLOC /* 调试使用 */
# define LOCK_PADSIZE (offsetof(struct raw_spinlock, dep_map)) 
        struct {
            u8 __padding[LOCK_PADSIZE];
            struct lockdep_map dep_map;
        };
#endif
    };
} spinlock_t;

/* 体系结构定义文件:arch/arm/include/asm/spinlock_types.h */
typedef struct raw_spinlock {
    arch_spinlock_t raw_lock; /* 根据cpu体系架构定义*/
#ifdef CONFIG_GENERIC_LOCKBREAK
    unsigned int break_lock;
#endif
#ifdef CONFIG_DEBUG_SPINLOCK
    unsigned int magic, owner_cpu;
    void *owner;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
    struct lockdep_map dep_map;
#endif
} raw_spinlock_t;

typedef struct { 
    union {
        u32 slock;
        struct __raw_tickets {
            u16 owner;
            u16 next;
        } tickets;
    };
} arch_spinlock_t; /* arm spinlock定义 */

spinlock常用操作

spinlock初始化

spin_lock_init初始化目的是把变量清零。

/* 文件:include/linux/spinlock.h */
#define spin_lock_init(_lock)               \
do {                            \
    spinlock_check(_lock);              \
    raw_spin_lock_init(&(_lock)->rlock);        \
} while (0)
# define raw_spin_lock_init(lock)               \
    do { *(lock) = __RAW_SPIN_LOCK_UNLOCKED(lock); } while (0)

/* 文件:include/linux/spinlock_types.h */
#define __RAW_SPIN_LOCK_UNLOCKED(lockname)  \
    (raw_spinlock_t) __RAW_SPIN_LOCK_INITIALIZER(lockname)

#define __RAW_SPIN_LOCK_INITIALIZER(lockname)   \
    {                   \
    .raw_lock = __ARCH_SPIN_LOCK_UNLOCKED,  \  /* 初始化为0*/
    SPIN_DEBUG_INIT(lockname)       \
    SPIN_DEP_MAP_INIT(lockname) }
    
/* 文件:arch/arm/include/asm/spinlock_types.h */
#define __ARCH_SPIN_LOCK_UNLOCKED   { { 0 } }

spinlock获取锁

spin_lock最终调用与体系架构相关的arch_spin_lock函数,arch_spin_lock使用了arm原子操作指令ldrex和strex。

执行路径:执行路径:spin_lock->raw_spin_lock->_raw_spin_lock->__raw_spin_lock->do_raw_spin_lock->arch_spin_lock

/* 文件:include/linux/spinlock.h */
static __always_inline void spin_lock(spinlock_t *lock)
{
    raw_spin_lock(&lock->rlock);
}
define raw_spin_lock(lock) _raw_spin_lock(lock)

/* 文件:include/linux/spinlock_api_smp.h */
#define _raw_spin_lock(lock) __raw_spin_lock(lock)
static inline void __raw_spin_lock(raw_spinlock_t *lock)
{
    preempt_disable();
    spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
    LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}
#define LOCK_CONTENDED(_lock, try, lock) \
    lock(_lock)

/* 文件:kernel/locking/spinlock_debug.c */
void do_raw_spin_lock(raw_spinlock_t *lock)
{
    debug_spin_lock_before(lock);
    arch_spin_lock(&lock->raw_lock);
    debug_spin_lock_after(lock);
}
/* 文件:arch/arm/include/asm/spinlock.h */
static inline void arch_spin_lock(arch_spinlock_t *lock)
{
    unsigned long tmp;
    u32 newval;
    arch_spinlock_t lockval;

    prefetchw(&lock->slock);
    __asm__ __volatile__(
"1: ldrex   %0, [%3]\n" /* 取lock地址内容赋值给本地变量lockval。lockval=lock->slock */
"   add %1, %0, %4\n" /* 将lockval->nexet加1后保存newval */
"   strex   %2, %1, [%3]\n" /* 把newval存在lock中,将是否成功结果存入在tmp中 */
"   teq %2, #0\n" /* 判断上条指令是否成功,如果不成功执行”bne 1b”跳到标号1执行 */
"   bne 1b"
    : "=&r" (lockval), "=&r" (newval), "=&r" (tmp) /* 3个输出变量分别对应%0,%1,%2 */
    : "r" (&lock->slock), "I" (1 << TICKET_SHIFT) /* 2个输入变量分别对应%3,%4 */
    : "cc");

    while (lockval.tickets.next != lockval.tickets.owner) { /* 判断本地持有的号码next是否与最新的owner相等 */
        wfe(); /* 如果不相等进入wfe模式,而不是while(1)死循环;等待其他CPU发送sev*/
        lockval.tickets.owner = ACCESS_ONCE(lock->tickets.owner);
    }

    smp_mb();
}

spinlock释放锁

释放锁相对简单,给锁的owner成员加1。

/* 文件:include/linux/spinlock.h */
static __always_inline void spin_unlock(spinlock_t *lock)
{
    raw_spin_unlock(&lock->rlock);
}
#define raw_spin_unlock(lock)       _raw_spin_unlock(lock)

/* 文件:include/linux/spinlock_api_smp.h */
#define _raw_spin_unlock(lock) __raw_spin_unlock(lock)
static inline void __raw_spin_unlock(raw_spinlock_t *lock)
{
    spin_release(&lock->dep_map, 1, _RET_IP_);
    do_raw_spin_unlock(lock);
    preempt_enable();
}

/* 文件:kernel/locking/spinlock_debug.c */
void do_raw_spin_unlock(raw_spinlock_t *lock)
{
    debug_spin_unlock(lock);
    arch_spin_unlock(&lock->raw_lock);
}

/* 文件:arch/arm/include/asm/spinlock.h */
static inline void arch_spin_unlock(arch_spinlock_t *lock)
{
    smp_mb();
    lock->tickets.owner++;  /* 将owner+1,然后发送sev命令*/
    dsb_sev();
}

spinlock接口总结

接口 接口作用
spin_lock_init 动态初始化spin lock
spin_lock 获取指定的锁。如果是多核系统会本地抢占,如果是单核系统只需要禁止本地中断即可。
spin_lock_irq 获取锁前,禁止本地中断
spin_lock_irqsave 获取锁前,保存当前irq开关状态,然后禁止本地中断
spin_unlock 释放锁,恢复本地抢占
spin_unlock_irq 释放锁后,打开本地中断
spin_unlock_irqstore 释放锁后,恢复保存的irq开关状态

读取者/写入者自旋锁

内核提供自旋锁的读取者/写入者形式,这种自旋锁和前面提到的读取者/写入者信号量非常类似。这种锁允许任意数量的读取者同时进入临界区,但写入者必须互斥访问。

读取者/写入者锁的类型为rwlock_t,定义在<linux/spinlock.h>。相关读写函数有

void read_lock
void read_lock_irqsave
void read_lock_irq
void read_unlock
void read_unlock_irqrestore
void read_unlock_irq

void write_lock
void write_lock_irqsave
void write_lock_irq
void write_unlock
void write_unlock_irqrestore
void write_unlock_irq

RCU锁

读多写少的一种锁机制。RCU(Read-Copy Update),顾名思义就是读-拷贝修改,它是基于其原理命名的。对于被RCU保护的共享数据结构,读者不需要获得任何锁就可以访问它,但写者在访问它时首先拷贝一个副本,然后对副本进行修改,最后使用一个回调(callback)机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据。这个时机就是所有引用该数据的CPU都退出对共享数据的操作。

它很少应用在驱动程序中,可考虑应用在网络路由表。

seqlock

seqlock提供对共享资源的快速、免锁访问。当想要保护的资源很小、很简单、会频繁访问但是很少修改且很修改快速时,就可以使用seqlock。从本质上来说,seqlock会允许读取者对资源的自由访问,但需要读取者检查是否和写入者发生冲突,如果冲突时,就需要读取者重新对资源的访问。

seqlock_init(seqlock_t *lock);
read_seqlock(seqlock_t *lock);
write_seqlock(seqlock_t *lock);

原子变量

有时,共享的资源可能只是一个简单的整数值,完整的锁机制对一个简单的整数来讲显得有些浪费。针对这种情况,内核提供了一种原子的整数类型,称为atomic_t,定义在<asm/atomic.h>。一个atomic_t变量在所有内核支持的架构上保存一个int值。针对这种整型的操作是非常快的,可能会编译成单个指令。

int atomic_read(atomic_t *v) // 返回当前值
void atomic_add(int i, atomic_t *v) // 将i增加到v中
void atomic_sub(int i, atomic_t *v)
void atomic_inc(atomic_t *v) // 增加或减一
void atomic_dec(atomic_t *v)

位操作

atomic_t类型对执行整数算术比较有用,但是对于操作单个的位时,这种类型就派不上用场。

void set_bit(nr, void *addr);
void clear_bit(nr, void *addr);

锁陷阱

恰当的锁定模式需要清晰和明确的规则。

  • 在编写代码时肯定会遇到几个函数均需要访问某个相同的锁。如果某个获得锁的函数要调用其他同样试图获取这个锁的函数,那么就会死锁。不论是信号量还是自旋锁,都不允许拥有者第二次获得这个锁。
  • 锁的顺序规则。使用大量锁的系统中,代码通常需要一次拥有多个锁,这时需要谨慎处理。加入有lock1和lock2。想象某个任务锁定了lock1,其他任务获得了lock2。这时每个任务都试图获取另外那个锁。于是两个任务都将死锁。因此需要规定获取锁的顺序。信号量与自旋锁的组合时,优先获取信号量,再获取锁。局部锁以及靠近内核的锁,优先获取局部锁。

细粒度锁和粗粒度锁的对比

第一个支持多处理器系统的Linux内核是2.0,其中有且只有一个锁。这个大的内核锁会让内核进入一个大的临界区,只有一个CPU可以在任意给定时间执行内核代码。这种机制不具有良好的伸缩性,发挥不了多核系统的优势。

在内核2.2版本开始支持子系统级别的锁,现代内核可包含数千个锁。细粒度锁本身有其成本,面临数千个锁,需要知道有哪些锁以及锁定的顺序,增加了操作锁的复杂性,对内核的可维护性产生很大的副作用。

参考文章

arm ldrex和strex指令:https://blog.csdn.net/Roland_Sun/article/details/47670099

嵌入式汇编符号含义:https://www.dazhuanlan.com/2019/10/04/5d962dde119c5/

为什么禁止抢占:https://blog.csdn.net/kasalyn/article/details/11473885

spinlock_t raw_spinlock区别:https://www.cnblogs.com/hadis-yuki/p/5540046.html

同步和互斥:https://blog.csdn.net/daaikuaichuan/article/details/82950711

posted @ 2020-09-01 20:24  zephyr~  阅读(1173)  评论(0编辑  收藏  举报