通过同步和加锁解决多线程的线程安全问题

点击量:331

在计算机多线程编程中,线程之间的安全问题是很重要的,它不仅关系到所需要的功能能否正确地实现,还关系到算法运行结果的稳定性等问题。当在多线程编程时,或者使用到的软件框架是具有多线程运行功能的时候,一名训练有素且技术过硬的合格程序员是会考虑程序在多线程环境下运行时的线程安全问题的,尤其是在多个线程间存在共享的资源的情况下。博主最近在使用Keras框架做深度学习训练的时候,就遇到了这样的问题,多线程时,Python的普通生成器会遇到异常。本文主要介绍两种实现多线程之间线程安全的方案,同步和加锁。

 

什么是多线程

我们都知道,在一个操作系统运行中,有许多个进程在工作,每一个进程都是某个存在于硬盘中的可执行程序执行状态的一个实例,是操作系统分配计算机资源的最小单元。线程则包含于每个进程中,是程序运行的最小单元,每个进程至少有一个线程。有时我们会根据需要,同时创建多个线程来运行,最简单的例子就是,创建图形界面的线程和其他各个功能模块的线程,以保证其他某个功能模块没有运行完毕时,图形界面不会出现卡死的问题,不然用户在等待过程中,会感到非常沮丧,导致用户体验极差。

 

线程不安全的例子

线程不安全的问题通常存在于多个线程有共享的资源的情况下,比如共享某个变量内存资源,或者共享打印机外部设备资源。假设某个变量A初始值为1,我们有这样的一个串行程序:

这是一个很简单的程序,仅仅是把A的值进行了加1。B可以指代任何存储单元,甚至是CPU中的某个寄存器单元。我们从来不会认为这样的一个程序会有bug存在,但是在多线程中,那就不一定了,甚至十有八九会出问题,哪怕仅仅是两个线程。假设A是一个共享的变量,为了区分两个线程,我们把变量B用B1和B2代替,我们想通过两个线程的运行让运行结束后变量A的值比原来加2:

但是结果是,运行结束后,变量的值仍然只加了1,而不是加了2,此时,线程并不安全。这样的程序,信息是具有易失性的。

不知道大家有没有发现,我在写运行过程时,中间是有空行的。这是因为,多线程程序运行时,多个线程之间,一般是并发执行的,也就是说,多个线程不是同时工作的,而是有先后顺序的,只不过CPU时间片很短,小到人难以察觉。在有的系统中,不同的线程会分配到不同的CPU上执行,这时程序是真正的并行执行,在同时计算,但是在对共享资源访问时,也总是有先后顺序的,真正同时的概率微乎其微,基本等于0。所以不论是并行,还是并发,我们总能得到类似于上图中的过程。

也许有人会说,变量直接+1不就可以了?学过汇编并了解计算机底层计算过程的同学应该清楚,即使是一条MOV汇编语句,在CPU执行时,也分为取指、译码、取变量1的地址、取变量2的地址、取变量2的值到寄存器、将寄存器的值写入变量1对应的高速缓存Cache中等等多个过程,而且高速缓存Cache的内容什么时候写回内存还有一套规则。所以,除了一些真正的原子操作原语以外,其他任何操作都不是原子性的,也就是说是多步骤执行的。

 

同步

同步的方案是将一段代码声明为原子操作,只有执行完毕才可以由其他线程执行,执行过程中不可中断,即使出现不可屏蔽的中断,也必须进行回退。这一种方案Java编程中用的较多,主要原因是Java语法原生支持“synchronized”关键字,可以使得某一段代码块的原子性和可见性。

格式:

synchronized(对象) {
  需要被同步的代码;
}

这里的锁对象可以是任意对象。在该代码块中,对指定的对象进行的操作具有原子性,其他线程在执行调用该对象的这段代码时,会被阻塞,保证只有一个线程能够处于执行这段代码的状态中。

 

加锁

加锁则是另一种技术了,但是二者有关联,可以认为上述的同步是锁的一种,即互斥锁。加锁一般分为互斥锁,可重入锁和读写锁。

可重入锁可以用来解决当代码块是递归调用时,普通的互斥锁会产生死锁的问题。因为某个线程已经获得锁,当它递归调用自己的时候,会再次申请加锁,此时就会死锁,而可重入锁可以在线程已经获取到锁的情况下,递归调用自己时,直接再次使用自己已经获得的锁。

读写锁的特点是,多个读者线程可以同时读,但是写者线程是互斥的,也就是同一时间最多只能有一个写者,而且也与读者线程优先,也就是当有写者时,优先调度写者线程运行,并将后续的读者线程调度为阻塞态。

 

线程安全的例子

当我们对上述提到的例子进行加锁后,我们的运行过程就变成了线程安全的。

最终,我们就得到了想要的结果A=3了。

 

同步和加锁的对比

从性能上来说,在资源的竞争不激烈的情况下,两中方式的性能接近,而在有大量线程同时激烈地竞争资源时,此时Lock的性能要远远优于synchronized。同步的互斥锁可重入但不可中断,排斥所有其他线程,即使二者都是读者也不行,此时读写锁更好。但同步的互斥锁在synchronized块结束时,会自动释放锁,在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;lock一般需要在finally中自己释放,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要手动在finally块中释放锁。我们在具体使用时,要根据实际情况做最合适的选择。

 

参考资料

博客:

https://www.cnblogs.com/aspirant/p/8657681.html

https://blog.csdn.net/OONullPointerAlex/article/details/50908375

https://stackoverflow.com/questions/261683/what-is-meant-by-thread-safe-code

https://proandroiddev.com/synchronization-and-thread-safety-techniques-in-java-and-kotlin-f63506370e6d

一个Python的线程安全版和非线程安全版的生成器实验代码:

https://gist.github.com/platdrag/e755f3947552804c42633a99ffd325d4

Keras Issues #1638:

https://github.com/keras-team/keras/issues/1638

 

版权声明
本博客的文章除特别说明外均为原创,本人版权所有。欢迎转载,转载请注明作者及来源链接,谢谢。
本文地址: https://blog.ailemon.me/2019/05/15/solving-multithreaded-thread-safety-problems-by-synchronization-and-locking/
All articles are under Attribution-NonCommercial-ShareAlike 4.0
打赏
微信
支付宝
微信二维码图片

微信扫描二维码打赏

支付宝二维码图片

支付宝扫描二维码打赏

留下评论

电子邮件地址不会被公开。 必填项已用*标注

13 + 8 =