并发编程漫谈

如果计算机只用来做一件事情,或者一件事情做完后再开始做第二件事情。那么根本不需要考虑什么并发。但是想象以下,我们的程序一定充斥着各种轮训(这就是我们大学时学的单片机,比如一会去检查下通讯就绪没有,一会又回去检查看门狗)。这无疑有一个基本要求,就是编程人员,哪怕是简简单单的一个业务,也必须熟悉计算机从硬件底层开始的所有知识,才能保证其可以正确处理各项操作的时序)。另外一方面,一旦我们有一个cpu负载很轻但是需要缓慢外设响应的操作,那么我们这件事就需要很慢很慢才能完成了。所以,并发是一种必然的策略。

硬件层面

内存屏障技术

现代CPU一般是SMP(对称多处理器)结构,多个处理器在独立的运行。完全会出现下面悲剧的情况,两个CPU同时执行i++,但是最终的结果却是2。
在这里插入图片描述
在x86平台上,CPU增加了一条引线(HLock)。一条汇编指令的前缀加上LOCK,这条引线就会变成0电位,从而把总线锁住。同时,其对缓存的操作将实时写到内存。其他CPU内核会在此时对总线进行嗅探,如果发现自己缓存中的数据此时被更改,也会将自己缓存值的该值进行无效化。这就是java中volatile变量保持可见性的原理。直到这条指令运行完成后会释放总线锁。依次来保证这条指令在多处理器环境中的稳定性。当然,小伙伴们也会发现,如果俩CPU同时试图拉低这条引线如何呢?放心吧,硬件工程师并非是吃干饭的,有各种硬件上解决信号线冲突的裁决方案。

内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障。
内存屏障有两个作用:

  1. 阻止屏障两侧的指令重排序;
  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效

对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;
对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。
对于JAVA中的volatile,实现原理如下

在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;

CAS操作

首先,CAS是一个CPU原语。意思是这条指令执行期间不允许CPU切换任务(也就是CPU此时不响应中断)。但是问题仍然存在,多核CPU的CAS操作又该如何处理呢?
CPU首先LOCK总线,然后查询其他CPU是否拥有该数据的缓存。如果没有,则从内存中读取数据。如果有,则要求该CPU把缓存的数据发给自己。进行修改后写回内存。最后释放总线。在实际设计中,上述的内存可以被优化为缓存,从而加快该指令的执行速度。

操作系统层面

信号量(Semaphore)

定义在semaphore.h中。
信号量是用于进程间换地信号的一个整数值。对信号量的常见操作就是P操作和V操作。整形信号量表示的是资源的数目S。P操作将减少一个资源,V操作将增加一个资源(即释放一个资源,因为资源不会被操作生出来,只会因别人不用而释放)。
P原语操作的主要动作是:

  • sem减 1;
  • 若sem减1后仍大于或等于零,则进程继续执行
  • 若sem减1后小于零,则该进程被阻塞后与该信号相对应的队列中,然后转进程调度。
    V原语的操作主要动作是:
  • sem加1;
  • 若相加结果大于零,进程继续执行;
  • 若相加结果小于或等于零,则从该信号的等待队列中唤醒一等待进程,然后再返回原进程继续执行或转进程调度。
    注意,PV原语不是CPU保证的,而是操作系统保证的。通过PV原语,操作系统可以保证两个进程的并发中的资源争用。
//初始化信号量
 int sem_init (sem_t* sem, int pshared, unsigned int value);
 //等待信号量
int sem_wait (sem_t* sem);
int sem_trywait (sem_t* sem);
//发送信号量
int sem_post (sem_t* sem);
//得到信号量值
int sem_getvalue (sem_t* sem);
//删除信号量
int sem_destroy (sem_t* sem);

功能:sem_wait和sem_trywait相当于P操作,它们都能将信号量的值减一,两者的区别在于若信号量的值小于零时,sem_wait将会阻塞进程,而sem_trywait则会立即返回。
sem_post相当于V操作,它将信号量的值加一,同时发出唤醒的信号给等待的进程(或线程)。
sem_getvalue 得到信号量的值。
sem_destroy 摧毁信号量
这是linux最早也的进程间同步和互斥的样子。

互斥锁

互斥锁和信号量的最大区别是,互斥锁只允许一个线程进入临界区。定义在/linux/include/linux/mutex.h中。一般概念中mutex就是N=1的Semaphore。

//互斥锁的初始化
int pthread_mutex_init (pthread_mutex_t* mutex,const pthread_mutexattr_t* mutexattr);
int pthread_mutex_lock(pthread_mutex_t* mutex); //上锁
int pthread_mutex_trylock (pthread_mutex_t* mutex); //只有在互斥被锁住的情况下才阻塞
int pthread_mutex_unlock (pthread_mutex_t* mutex); //解锁
int pthread_mutex_destroy (pthread_mutex_t* mutex); //清除互斥锁

条件变量(pthread_cond_t)

条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足(没办法在实际获取锁前就检查条件是否满足,设想一个线程被锁阻塞了半天,好不容易竞争到锁了,结果发现根本不满足自己的条件只能重新放弃锁继续等待),它常和互斥锁一起使用。使用时,条件变量被用来阻塞一个线程,当条件不满足时,线程往往解开相应的互斥锁并等待条件发生变化。一旦其它的某个线程改变了条件变量,它将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。 这些线程将重新锁定互斥锁并重新测试条件是否满足。

自旋锁

定义在spinlock.h中。

void spin_lock(spinlock_t *lock); //最基本得自旋锁函数,它不失效本地中断。
void spin_lock_irqsave(spinlock_t *lock, unsigned long flags);//在获得自旋锁之前禁用硬中断(只在本地处理器上),而先前的中断状态保存在flags中
void spin_lockirq(spinlock_t *lock);//在获得自旋锁之前禁用硬中断(只在本地处理器上),不保存中断状态
void spin_lock_bh(spinlock_t *lock);//在获得锁前禁用软中断,保持硬中断打开状态

JAVA

happens-before

在JSR文件中,对happens-before有如下定义。

Two actions can be ordered by a happens-before relationship. If one action happens-before another, then the first is visible to and ordered before the second.

具体有如下约定

  1. 线程内部规则:在同一个线程内,前面操作的执行结果对后面的操作是可见的。
  2. 同步规则:如果一个操作x与另一个操作y在同步代码块/方法中,那么操作x的执行结果对操作y可见。
  3. 传递规则:如果操作x的执行结果对操作y可见,操作y的执行结果对操作z可见,则操作x的执行结果对操作z可见。
  4. 对象锁规则:如果线程1解锁了对象锁a,接着线程2锁定了a,那么,线程1解锁a之前的写操作的执行结果都对线程2可见。
  5. volatile变量规则:如果线程1写入了volatile变量v,接着线程2读取了v,那么,线程1写入v及之前的写操作的执行结果都对线程2可见。
  6. 线程start原则:如果线程t在start()之前进行了一系列操作,接着进行了start()操作,那么线程t在start()之前的所有操作的执行结果对start()之后的所有操作都是可见的。
  7. 线程join规则:线程t1写入的所有变量,在任意其它线程t2调用t1.join()成功返回后,都对t2可见

Monitor机制

是老JDK内部维护并发同步和互斥的核心

.如果一个java对象被某个线程锁住,则该java对象的Mark Word字段中LockWord指向monitor的起始地址。Monitor是一种用来实现同步的工具。即每个被锁住java对象都有一个Monitor数据段与之对应。Monitor是实现Sychronized(内置锁)的基础。

  1. Owner字段:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL
  2. EntryQ字段:关联一个信号量(semaphore),阻塞所有试图锁住monitor record失败的线程
  3. RcThis字段:表示blocked或waiting在该monitor record上的所有线程的个数
  4. Nest字段:用来实现重入锁的计数
  5. HashCode字段:保存从对象头拷贝过来的HashCode值(可能还包含GC age)
  6. Candidate字段:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降;Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁
    可以看出,Monitor机制就是一层Semaphore的封装。
    为什么使用Semaphore而非Mutex?这是因为直到2005年,linux社区的mutex机制才逐步成熟。这要感谢RedHat公司的奉献。

JUC机制

这个也是我们java程序员最熟悉的。其核心就是尽量使用CAS,自旋,CLH队列线程调度来优化原有锁。其中,线程调度park采取的是pthread_cond_wait
,unpark采取的是pthread_cond_signal。

pthread_cond_wait (pthread_cond_t *cond, pthread_mutex_t *mutex);

这是一个条件等待的API。其中mutex是本线程已经持有的互斥锁。如果条件不满足,线程将放弃互斥锁,进入等待队列。别的线程发现条件满足后,会采用pthread_cond_signal或pthread_cond_broadcast方式来通知它。这样,它就会去竞争锁,如果竞争到了,则加锁继续执行。

猜你喜欢

转载自blog.csdn.net/define_us/article/details/84860541