线程同步
我们都知道,竞态的形成即是资源的争用。而资源存在非常多的形式,比如变量,对象, CPU, buffer, 网络,磁盘(文件), 外设的所有权,服务等。
而对资源的不恰当利用可能导致程序低效的运行,资源泄漏,死机,崩溃等。
本文为笔者的一些经验总结,当然也参考了不少文档。试图详尽的介绍与此相关的问题。
当然要做到全面是非常难的,毕竟现实生活中遇到的问题五花八门,完全不知道会在什么时候会出现一个吓你一跳的黑天鹅。
并发,指同一个系统拥有多个计算进程(或者进程),这些进程有同时执行与的潜在交互特性,因此系统会有相当多个执行路径且结果可能具有不确定性。并发计算可能会在具备多核心的同一个芯片中交错运行,以优先分时线程在同一个处理器中执行,或在不同的处理器执行。 -- wikipedia
解决并发过程出现的竞态我所知道的有两个方法:方法之一是给资源上锁;方法二是使用异步单线程方式实现( node.js )。
异步暂时不再此文讨论范畴,因此这里简要介绍一下异步单线程编程的一些需要注意的问题。
- 无法利用多核性能。
- 需要严格控制过程的是时间片,防止某些过程获取不到资源。
- 虽然不要原子性,但是还是需要妥善的对资源进行管理。最好的办法是进入过程获取资源,退出过程释放资源。其次你需要一些管理资源的手段,方便你随时查看当前资源的使用情况(比如说状态变量,计数器);
- 不能存在 while 轮询,你只能依靠下一次 CPU 资源什么时候轮到你,因此你也不能够相对精确的在某个时间点去做某件事。这个其实和第 2 点是一致的。
我理解的异步单线程编程即单线程事件编程。
下面是一个用伪代码实现的例子:
task1_private_data task1(){ ... PUSH_TASK( taskX, taskX_private_data ) ... } INTRO(){ // do what you do PUSH_TASK( task1, task1_private_data ) } THREAD{ INTRO() LOOP{ WAIT_WAKE_UP() LOOP{ GET TASK & TASK_PRIVARE_DATA from TASK_LIST CALL TASK( TASK_PRIATE_DATA ) } } } PUSH_TASK( TASH, TASK_PRIVATE_DATA ){ PUSH TASK & TASK_PRIATE_DATA PAIRE to TASK_LIST WAKE_UP THREAD }
首先,我们先来讨论一下死锁这个问题。我自己把死锁分成了以下这几个类别:单线程死锁,多线程死锁,不完全死锁。
首先看单线程死锁,单线程死锁的模式如下所示:
THREAD{ LOCK A ... LOCK A ... }
我觉得有一定经验的人一般不会傻到写出这种代码,但是下面这种情况嘞:
CLASS A{ LOCK self LOCK_RESOURCE(){ self_lock.lock() return .... } UNLOCK_RESOURCE(){ self_lock.unlock() } } class B{ A a; DO_PROCESS_1(){ a.LOCK_RESOURCE(); } ... DO_PROCESS_n(){ b.UNLOCK_RESOURCE(); } } // 这种例子 THREAD_1{ B b; b.DO_PROCESS_1(); ... b.DO_PROCESS_1(); } // 还有这种例子 B b; THREAD_2 or PROCESS_2{ b.DO_PROCESS_1(); ... if CONDITION_1: return; ... b.DO_PROCESS_n(); }
还有一种情况,如下所示:
// in PROCESS_1 LOCK *A = new LOCK; // in PROCESS_2 LOCK *B = A; // in PROCESS_3 delete A; // in PROCESS_4 LOCK B; // 死锁 -- 因为 B 指向的地址已经被释放,因此 B 地址指向的数据可能是任意状态的。
当然如果有这种问题,在项目前期也许很容易暴漏,但如果 THREAD_2 or PROCESS_2 中的 CONDITION_1 很难满足,而你的测试样例又没有覆盖到的话,那么这可能就成为你应用中的一个炸弹了。
上面列举的例子还仅仅是锁,锁的话发现问题了还比较方便定位。如果上面被阻塞的不是锁,而是文件(O_EXCL 或者 "wx" "wbx" "w+x" "wb+x" "w+bx" 模式打开的文件)?一个网络阻塞性的 read 函数?一个可被设置为独占的驱动,外设?那有怎么办嘞?
因此,你不仅仅是需要对你所使用的语言烂熟于心,还必须对你模块中涉及到的所有可能会阻塞或者因条件而阻塞的地方,以及模块于模块之间的业务逻辑(交互逻辑)心知肚明。
下面我们再来看一看多线程的例子,死锁的通用形式如下所示:
LOCK a; LOCK b; THREAD_1{ ... a.lock() ... b.lock() ... b.unlock() ... a.unlock() ... } THREAD_2{ ... b.lock(); ... a.lock(); ... a.unlock(); ... b.unlock(); }
当然,这些 lock 可能会隐藏再各种判断条件下,或者藏在各种调用的方法过程中,这些设计,可能会将这个模式隐藏德很深。甚至躲过你自信满满的,不完全的测试样例。
再看这个例子,这个例子我们看不到以一个锁,但是 THREAD_3 可能就一直停在那里,(也可能偶尔能运行一下,让你甚是糊涂):
THREAD_1:{ LOOP(){ WAIT LIST_A MESSAGE; GET msg FROM LIST_A DO SOMETHING PUSH to LIST_B } } THREAD_2:{ LOOP(){ WAIT LIST_B MESSAGE; GET msg FROM LIST_B DO SOMETHING } } THREAD_3{ PUSH msg to LIST_A WAIT msg result from B // or PUSH msg to LIST_B WAIT msg result from A }
我从这些例子里面得到的感悟是基础是
1. 千里之行,始于足下。千里之堤,溃于蚁穴。基础是很重要的,细节也很重要。我们需要不断的熟练自己的技能。才能如庖丁解牛,游刃有余。
2. 设计模式不仅仅是书上明确的那些既定的东西,它是一种思维工具, 是刀,是锯,是改锥,也是你自己总结提炼的最佳实践。良好的设计风格是非常重要,多学多想多思考,沉淀出属于自己的一套设计模式是很重要的。
3. 良好设计的关键在于对问题的深入认识,而不是提供了多少高级的特征。 -- 当然更不是不假思索的找了一个解决当前问题的方案即可。因此深入理解业务逻辑是非常重要的。
-
《深入理解并行编程》中文版 -
《LINUX设备驱动程序》 第四章
int pthread_mutex_destroy(pthread_mutex_t *mutex); int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; 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_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr); int pthread_rwlock_destroy(pthread_rwlock_t *rwlock); pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER; int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock); int pthread_rwlock_timedrdlock(pthread_rwlock_t *restrict rwlock, const struct timespec *restrict abstime); int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock); int pthread_rwlock_timedwrlock(pthread_rwlock_t *restrict rwlock, const struct timespec *restrict abstime);
读写锁的规则如下所示:
- 如果某个线程已经读锁定,其他试图进行写锁定的线程会被阻塞,其他试图进行读锁定的线程也会被阻塞;
- 如果某个线程已经写锁定,其他试图写锁定的线程依旧会锁定获得锁定状态,可以并行读
- 如果某几个线程已经写锁定,其他试图写锁定的线程到达,那么新进的读锁定线程到达一般会阻塞(即有限写锁定线程),该操作是为了防止读线程循环占用资源,导致读线程无法获取资源。 当然,写线程也要等待当前已经锁定的读线程全部释放锁之后才能成功锁定。
- 读写锁在使用之前一定要初始化,而且在释放底层内存之前必须销毁。
条件变量要搭配互斥锁使用:
int pthread_cond_destroy(pthread_cond_t *cond); int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr); pthread_cond_t cond = PTHREAD_COND_INITIALIZER; int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime); int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex); int pthread_cond_broadcast(pthread_cond_t *cond); int pthread_cond_signal(pthread_cond_t *cond);
int pthread_spin_init(pthread_spinlock_t *lock, int pshared); int pthread_spin_destroy(pthread_spinlock_t *lock); int pthread_spin_lock(pthread_spinlock_t *lock); int pthread_spin_trylock(pthread_spinlock_t *lock); int pthread_spin_unlock(pthread_spinlock_t *lock);
在应用层,自旋锁并不是非常有用,除非运行在不允许抢占的实时调度类中。运行在分时调度类中的用户层线程在两种情况下可以被取消调度。 当他们的时间片到期时,或者具有更高优先级的线程从就绪状态变成可运行时。 在这个时候,如果线程拥有自旋锁,他就会进入休眠状态,阻塞在锁上的其他线程自旋时间可能会比预期的时间更长。
而且有些平台互斥锁的实现效率非常高,因此也不需要使用自旋锁来节省资源。
且自旋锁的作用与互斥锁类似,但是它不是通过休眠实现的,而是通过忙等待实现的,从一定程度上来将,这有点浪费资源。
barrier 主要用于将多个线程同步到目标点,比如我们需要让多个线程从某一行开始执行,或者让多个线程都停在目标位置。
一个典型的场景是科学计算,当你有一台多核处理器(比如 64 ),且要处理一个超大规模的数据集的时候就可以使用到次特性(当然也有其他方法进行同步), 首先将计算任务分成 64 个子任务,然后在调用任务函数后,调用 barrier wait, 当所有的任务都到达的时候,主线程会推出 wait 函数,开始合并计算结果,从而达到加速的目的。
int pthread_barrier_destroy(pthread_barrier_t *barrier); int pthread_barrier_init(pthread_barrier_t *restrict barrier, const pthread_barrierattr_t *restrict attr, unsigned count); int pthread_barrier_wait(pthread_barrier_t *barrier);