进程同步

概览进程同步器件的实现方法关系图

原子操作

原子操作:顾名思义就是不可分割的操作,该操作只存在未开始和已完成两种状态,不存在中间状态;

原子类型:被原子操作的变量就是原子类型。比如TAS指令中,TAS指令即为原子操作, 被TAS操作的变量lock就是原子类型。

原子变量可以保证一个变量单次操作的正确性,其保护甚至比信号量还完善,信号量只能保护全局数据不被其他线程破坏,而原子变量能保证全局数据不被中断破坏。

int a;

int b,c;

a = a + b + c;

上述代码中,cpu对a有一个读、修改、写的过程,这个过程如果被打断,并在其他线程中修改了a值,执行结果将出现错误,而原子变量将保证不会发生这样的错误

若把上述代码改为

atomic_t a;

int b,c;

atmotic_add(&a , b ); //L1

atmotic_add(&a , c ); //L2

原子变量不能保证L1和L2两行程序间a不被其他线程修改

在单CPU中原子操作由一条硬件指令实现,比如test_and_set指令、compare指令、i++指令等、i+=val指令。例如以下均为磁CPP中的原子指令。以下均为常见原子操作

测试并设置(Test-and-Set)

获取并增加(Fetch-and-Increment)

交换(Swap)

比较并交换(Compare-and-Swap)

加载链接/条件存储(Load-Linked/Store-Conditional,LL/SC)

TAS/SWAP指令实现原理

TAS(Test And Set)汇编指令执行过程的伪代码如下:

boolean test-and-set(*lock)

{

    boolean old=*lock;

    *lock=true;

    return old;

}

Swap汇编指令执行过程的伪代码如下,其实下面整个过程都是在一条汇编指令内完成的。

void swap(boolean *a,boolean *b)

{

    boolean temp=*a;

    *a=*b;

    *b=temp;

}

自旋锁

自旋锁由TAS指令、swap指令实现。

用linux api实现自旋锁

用TAS指令实现spinlock

while(test_and_set(&lock));//自旋锁

//临界区

lock=FALSE;//释放锁

用swap指令实现spinlock

key=TRUE; 

do{swap(&key,&lock);}while(key!=FALSE);//自旋锁

//临界区

lock=FALSE;//释放锁

用C++11 api实现自旋锁

用test_and_set指令实现spinlock

std::atomic_flag lock = ATOMIC_FLAG_INIT;

while(lock.test_and_set(std::memory_order_acquire));//自旋锁

//临界区

lock.clear(std::memory_order_release);//释放锁

std::atomic_flag lock = ATOMIC_FLAG_INIT;

while (lock_stream.test_and_set()) ;

//

lock_stream.clear();

采用class封装可以用于lock_guard或unique_lock

class spinlock_mutex

{

  std::atomic_flag flag;

public:

  spinlock_mutex():

  flag(ATOMIC_FLAG_INIT){}

  void lock()

  {

    while(flag.test_and_set(std::memory_order_acquire));

  }

  void unlock()

  {

    flag.clear(std::memory_order_release);

  }

};

用swap指令实现spinlock

互斥锁

关中断实现互斥锁

为什么有了关中断还要采取其他方法?

这种方式有一个明显的缺陷,也就是绝对互斥的,即同一时间只有一个线程能访问该对象资源,但很多时候并不需要这种绝对的单线程互斥,我们希望相同种类的资源内部互斥就OK,访问不同资源的仍可以被调度执行

lock(&mutex){

cli();

while(mutex==0)

sleep_on(mutex_queue);//函数内会开中断

--mutex;

sti();

}

unlock(&mutex){

cli();

++mutex;

wakeup(mutex_queue);

sti();

}

用自旋锁方式实现互斥锁

使用下述方法实现互斥锁会出现一个问题——执行休眠后,无法解开自旋锁

lock(&mutex)

{

while(test_and_set(&lock));

while(mutex==0)

sleep_on(mutex_queue);

--mutex;

lock=false;

}

unlock(&mutex)

{

while(test_and_set(&lock));

wakeup(mutex_queue);

--mutex;

lock=false;

}

为解决这个问题需要用到Two-phase 锁,比较难理解,现阶段不做深入。https://zhuanlan.zhihu.com/p/68750396

纯软件方式实现互斥锁

即用Peterson算法实现对互斥锁的互斥访问,一般不会用到,不作赘述。

信号量

    信号量分为有名信号量、无名信号量。

    无名信号量主要用于线程间的通信,保存在内存中,如果想要在进程间同步就必须把无名信号量放在进程间的共享内存中。而在进程间的通信中同步用的通常是有名信号量。有名信号量一般保存在/dev/shm/ 目录下。像文件一样存储在文件系统中。

    其实现方法同互斥锁一样,可通过关中断、自旋锁、纯软件方法实现,不再赘述。信号量的值表示可用资源数,故最小为0。

信号量的实现方法

错例:

正确例子:

value—放在进程休眠后,保证了信号量为非负;

while:当同时唤醒多个进程进入就绪态后,由时钟中断调度就绪态的进程,从而保证所有这些被唤醒的进程只有一个能执行进入临界区执行value--

单cpu尽量使用关中断实现信号量;多cpu尽量使用CAS实现信号量;可以使用自旋锁、互斥锁实现信号量,但是执行进程阻塞时,需要拆分schedule,先把进程放入等待队列、保存现场、再解锁,最后修改cs:eip,这使得编程复杂化。

//信号量--操作,判断是不是要阻塞

P()

{

    cli();//关中断

    //遍历阻塞队列所有被唤醒的进程中分配了信号量的,也就是!=0的

    while(sem->value == 0)

    {

        将当前进程加入到阻塞队列;

    }

    sem->value--;

    sti();//开中断

}

//信号量++操作,判断是不是要唤醒

V()

{

    cli();//关中断

    sem->value++;

    //让阻塞队列中所有进程全部被唤醒,也就是变成就绪态,进入就绪队列

    //if 阻塞队列中没有进程 就不需要做啥

    sti();//开中断

}

信号量可通过互斥量、条件变量实现

条件变量

条件变量的理解核心pthread_cond_wait。该函数实现了休眠+解锁原子操作。具体实现方法:

1. Two-phase 锁;

2. 先将阻塞进程放入等待队列,再解锁,再调度修改cs:eip。

条件变量和信号量的区别

Conditional variable is essentially a wait-queue

Note that to use a conditional variable, two other elements are needed:

a condition (typically implemented by checking a flag or a counter)

a mutex that protects the condition

Semaphore is essentially a counter + a mutex + a wait queue. And it can be used as it is without external dependencies.

Therefore, semaphore can be treated as a more sophisticated structure than conditional variable, while the latter is more lightweight and flexible.

使用条件变量condition variable时,需要mutex

核心点:两个队列——阻塞在条件变量上的队列、阻塞在互斥锁上的队列;阻塞在条件变量上的队列为空时,signal信号为无效操作;

linux中的条件变量

以生产者消费者为例

pthread_cond_t qready = PTHREAD_COND_INITIALIZER;

pthread_mutex_t qlock = PTHREAD_MUTEX_INITIALIZER;

consumer (struct msg *mp)

{

struct msg *mp;

for (;;) {

pthread_mutex_lock(&qlock);

while (workq == NULL)

pthread_cond_wait(&qready, &qlock);

从队列中取出元素;

pthread_mutex_unlock(&qlock);

/* now process the message mp */

}

}

producer (struct msg *mp)

{

pthread_mutex_lock(&qlock);

mp->m_next = workq;

向队列中添加元素;

pthread_cond_signal(&qready);

}

int pthread_cond_signal(pthread_cond_t * cond);

pthread_cond_signal通过条件变量cond发送消息,若多个消息在等待,它只唤醒一个。

pthread_cond_broadcast可以唤醒所有。调用pthread_cond_signal后要立刻释放互斥锁,因为pthread_cond_wait的最后一步是要将指定的互斥量重新锁住

为什么要与pthread_mutex 一起使用呢?

核心是理解pthread_cond_wait(&m_cond,&m_mutex)的执行过程:

 pthread_cond_wait()发现条件变量等于0时,把进程挂到等待队列上,释放锁,进行进程切换;

 当pthread_mutex_unlock(&m_mutex)解锁并执行pthread_cond_signal(&m_cond)唤醒进程后,pthread_cond_wait()会先加锁,再继续执行。

为什么要使用while?可否使用if?

答案是不可以用if替代while,一个生产者可能对应着多个消费者,生产者向队列中插入一条数据之后发出signal,然后各个消费者线程的pthread_cond_wait获取mutex后返回,当然,这里只有一个线程获取到了mutex,然后进行处理,其它线程会pending在这里;

获得锁的这个消费者线程处理完毕之后释放mutex,刚才等待的线程中有一个获取mutex,如果这里用if,就会在当前队列为空的状态下继续往下处理,这显然是不合理的。

pthread_cond_signal放在pthread_mutex_unlock之前还是之后?

之前:在某下线程的实现中,会造成等待线程从内核中唤醒(由于cond_signal)然后又回到内核空间(因为cond_wait返回后会有原子加锁的 行为)。但是在LinuxThreads或者NPTL里面,就不会有这个问题,因为在Linux 线程中,有两个队列,分别是cond_wait队列和mutex_lock队列, cond_signal只是让线程从cond_wait队列移到mutex_lock队列。

之后:不会出现之前说的那个潜在的性能损耗,因为在signal之前就已经释放锁了。但如果unlock和signal之前,有个低优先级的线程正在mutex上等待的话,那么这个低优先级的线程就会抢占高优先级的线程(cond_wait的线程),而这在上面的放中间的模式下是不会出现的。

C++11中的条件变量

std::condition_variable

void wait (unique_lock& lck);

template void wait (unique_lock& lck, Predicate pred);

notify_one()

notify_all()

管程Moniters

在并发领域,有两个核心问题:一个是互斥,一个是同步。

 互斥:同一时刻只允许一个线程访问共享资源。

 同步:线程之间如何通信、协作。

使用信号量时要搭配互斥锁一起使用,比较繁琐,故引入管程。

管程是一个编程语言概念。编译器必须要识别出管程并用某种方式对互斥做出安排。C、Pascal及多数其他语言都没有管程,所以指望这些编译器来实现互斥规则是不可靠的。java有管程。

管程的实现方式

hoare管程、hansen管程。


管程主要操作:enter过程、leave过程、条件型变量c、wait(c) 、signal(c)

管程的实现中有如下的队列:

进入队列(Entry Queue),保存尝试从外部访问管程程序的进程,每个管程至少有一个进入队列

被唤醒的队列(Signaller Queue),保存刚刚执行了V操场的进程(signal)

等待队列(Waiting Queue),保存刚刚被V操作唤醒的进程

条件变量队列(Condition Variable Queue),保存刚刚执行了条件变量Wait(P)操作的进程

解决互斥问题

管程解决互斥问题的思路很简单,就是将共享变量和对共享变量的操作统一封装起来。在下图中,管程 X 将共享变量 queue 这个队列和相关的操作入队 enq()、出队 deq() 都封装起来了;线程 A 和线程 B 如果想访问共享变量 queue,只能通过调用管程提供的 enq()、deq() 方法来实现, enq()、deq() 保证互斥性,只允许一个线程进入管程。



解决同步问题

如图当多个线程申请进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。那条件变量和条件队列的作用是什么呢?其实就是解决线程同步问题的。


由于本人暂时不学java,故以后补充这个知识点……..

纯软件方式实现互斥访问

Peterson算法


flag[0]表示进程0想进入临界区,flag[1]表示进程1想进入临界区

所以当flag[1]为true时,进程0就执行while(flag[1])空循环,不进入临界区

为什么还需要turn呢?

当flag[0]、flag[1]均为true时,两个进程都会执行空循环,都不能进入临界区。而引入turn后,turn只能为0或者1中的一个,两进程不可能同时执行空循环,必有一个进入临界区。

进程同步(多cpu)

CAS硬件指令由总线锁实现,自旋锁、互斥锁由CAS实现。

CAS(Compare And Swap)是一种原子操作,用于保证在无锁情况下的数据一致性的问题。在无锁情况下,假设有两个线程 A 和 B,他们都读取某一个值 V,修改后再存回内存中,当它们并行执行时,就可能会引起数据 V 的不一致问题。

CAS靠硬件实现,是一条CPU的原子指令,基于汇编指令cmpxchg(Intel x86)实现,其作用是让CPU先比较存放在两个cpu缓存中的两个值是否相等,然后原子性地更新其中一个值。

在多CPU下原子操作++i,i+=val等由硬件指令CAS实现。

CAS指令实现原理

1、通过总线锁

当一个处理器想要更新某个变量的值时,向总线发出LOCK#信号,此时其他处理器的对该变量的操作请求将被阻塞,发出锁定信号的处理器将独占共享内存,于是更新就是原子性的了。

2、通过缓存锁定(利用CPU缓存一致性)

当某个处理器想要更新主存中的变量的值时,如果该变量在CPU的缓存行中,执行写回主存操作时,CPU通过缓存一致性协议,通知其它处理器使其它处理器上的缓存失效并重新从主存读取,以此来保证原子性。

不一致问题的举例

假设有两个线程 A 和 B,它们分别对数据 V(值为100)执行加 10 和减 10 的操作,代码执行过程如下:

线程A对数据V的操作:

 从内存中读取数据 V(100);

 (在线程中)将数据 V加10;

 将加法的结果 V1(110)存入内存中原来的位置(替换掉旧的 V)。

线程 B 对数据 V 的操作:

 从内存中读取数据 V(100);

 (在线程中)将数据 V 减 10;

 将减法的结果 V2(90)存入内存中原来的位置(替换掉旧的 V)。

假设这两个线程并发执行,且 A 首先获得 CPU 时间片,在 A 的 CPU 时间内,它先读取数据 V 的值,并将其进行了加法操作,获得数据 V1(110)。此时,A 的 CPU 时间片结束,线程 B 开始执行。B 将数据 V 读入(此时数据V未被改动),并执行了减法操作,获得数据 V2(90)。此时,B 的 CPU 时间片结束,线程 A 继续执行,A 将 V1(110)存入内存,A 线程结束。B 继续执行,B 将 V2(90)存入内存,B 线程结束。

我们可以看到,此时内存中的数据 V 已经变成了 V2(90),与我们原先以为的100(加十减十)预期不同,造成了数据不一致的问题。

使用CAS解决数据不一致问题

CAS 可以用于解决上述数据不一致问题,假设线程 A 和 B 都使用了 CAS 方式,那么他们的执行步骤为:

线程 A 对数据 V 的操作:

 从内存中读取数据 V(100);

 (在线程中)将数据 V 加 10;

 执行 CAS 操作,比较第一步读取的 V 值(100)与现在内存中的 V 值是否相等,若相等则继续;否则返回执行第一步;

 将加法的结果 V1(110)存入内存中原来的位置(替换掉旧的 V)。

线程B对数据V的操作:

 从内存中读取数据 V(100);

 (在线程中)将数据 V 减 10;

 执行 CAS 操作,比较第一步读取的 V 值(100)与现在内存中的 V 值是否相等,若相等则继续;否则返回执行第一步;

 将减法的结果 V2(90)存入内存中原来的位置(替换掉旧的 V)。

流程修改后,在执行过程中,当 A 线程执行结束后,内存中的值已经变为 V1(110),线程B在存入新的值之前首先比较 V1 是否与 V 相同,因为内存中的值已经修改,所以线程B需要重新执行读取操作,从第一步重新执行,将 V1(110)减 10 在存入内存,得到 V(100)与预期一致,从而确保了数据的一致问题。

进程同步在内核中的应用

什么情况不允许内核抢占

  有几种情况Linux内核不应该被抢占,除此之外Linux内核在任意一点都可被抢占。这几种情况是:

  内核正进行中断处理。在Linux内核中进程不能抢占中断(中断只能被其他中断中止、抢占,进程不能中止、抢占中断),在中断例程中不允许进行进程调度。进程调度函数schedule()会对此作出判断,如果是在中断中调用,会打印出错信息。

  内核正在进行中断上下文的Bottom Half(中断的底半部)处理。硬件中断返回前会执行软中断,此时仍然处于中断上下文中。

内核的代码段正持有spinlock自旋锁、writelock/readlock读写锁等锁,处于这些锁的保护状态中。

内核正在执行调度程序Scheduler。抢占的原因就是为了进行新的调度,没有理由将调度程序抢占掉再运行调度程序。

你可能感兴趣的:(进程同步)