多线程环境下,程序运行真是危机四伏

Thread-safe code only manipulates shared data structures in a manner that ensures that all threads behave properly and fulfill their design specifications without unintended interaction. 线程安全代码仅以确保所有线程正常运行并满足其设计规范而不会发生意外交互的方式操作共享数据结构。

  • 线程不安全的代码
    -- i++
    -- 共享代码段
    -- long float等多字类型
  • 思考
  • 解决思路

我们先来看几个线程不安全的场景:

线程不安全场景1

我们以常见的一行代码++i ,--i 为例, 计算机的操作姿势可能与你想象的不一样。

在大多数计算机中, 给变量自增并不是原子操作, 需要下面三步:
① 将变量值加载进寄存器
② 寄存器自增/自减值
③ 将寄存器值加载回原变量

多线程环境下,如果你不使用一些原子锁操作:
线程A ( ++i )可能只执行了前面两步后,之后CPU轮询切换到其他线程或者线程A被抢占CPU; 线程B ( --i )欻欻执行完所有的三步;

当线程A重新获得CPU,执行第三步, 一下子影响了线程B的执行预期。

上面的问题可以使用原子锁Interlocked(由硬件支持), https://docs.microsoft.com/en-us/dotnet/api/system.threading.interlocked?view=net-5.0。

线程不安全场景2

最近大意了,竟然想将《面试官:实现一个带值变更通知能力的Dictionary》一文中的临界锁只应用到写操作。

内心旁白:读操作又不会修改数据,无论是新值还是旧值,反正能读到。

不过我又快速清醒了,临界锁还真就得这么加。
临界锁的目的是保证这一段代码逻辑不会被打断。

假如只应用写锁:

某线程执行到写锁前(刚触发了一次变更通知),这时cpu时间片轮转或抢占, 切换到另外的线程又把这段代码执行了一次(因为字典key-value还没被前线程覆写),这样一次value变更实际执行了两次变更操作,这就悲剧了。


再回到我们常见的数据结构: 队列,集合,堆栈等,这些集合的公开成员都不是线程安全的,理由参考上面,每个公开成员的操作都是有多个步骤完成,都涉及对于共享资源collection的变更。

.NET 在System.Collections.Concurrent 命令空间提供了对应的线程安全的集合。
https://learn.microsoft.com/en-us/dotnet/standard/collections/thread-safe/

  • ConcurrentQueue(T) vs. Queue(T)
  • ConcurrentStack vs. Stack
  • ConcurrentDictionary vs. Dictionary
  • BlockingCollection 具有阻塞和缓冲区边界能力的集合
  • ConcurrentBag 可用来实现对象池

线程安全场景3

还没完, 我从微软官方原子操作找到一段话:

Reads and writes of the following data types are atomic: bool, char, byte, sbyte, short, ushort, uint, int, float, and reference types. In addition, reads and writes of enum types with an underlying type in the previous list are also atomic. Reads and writes of other types, including long, ulong, double, and decimal, as well as user-defined types, are not guaranteed to be atomic. Aside from the library functions designed for that purpose, there is no guarantee of atomic read-modify-write, such as in the case of increment or decrement.

直译起来:
① bool char byte sbyte uint int float 和引用类型上的读写是原子操作;

② 由以上类型定义的枚举类型操作也是原子类型;

③ long ulong double decimal和用户定义类型上的读写不保证是原子操作;

④ 除了库文件本身设计了线程安全,一般况下下都不保证读写是原子操作, 这也包括i++i--


https://github.com/dotnet/csharpstandard/issues/372
这段文字是不是刷新了某些童靴的认知(包括在下):

  1. 以后使用long num=8888;时要留个心眼,你也许会读到long类型的部分字节。

  2. 直译第①点说引用类型的读写是原子操作,第③点说用户类型不保证原子操作,但是大部分的用户类型是引用类型,这不互相矛盾了吗?

我向微软官方提出了我的这个疑问,有兴趣可以关注这个github issue

说说我的看法:

直译第①点中各种类型的读写操作是原子操作: 应该想表达式是纯粹的赋值、引用操作, 比如

int a =1;   				                        //  赋值:  线程安全
Student s = new Student {}; 	          // 引用赋值: 线程安全
Student  s2= s;                           // 引用赋值: 线程安全

针对引用类型Dictionary的其他操作自然不是线程安全的。

依据这个思路, 第①③点就不矛盾了。

思考

上面几个场景:

  • 简单的 i++ 代码在多线程调度的微观执行导致的不符预期
  • 宏观的大段共享代码在多线程调度下的执行不符预期。

总结起来: 线程不安全: 是由于cpu线程轮换或抢占, 导致原共享代码段执行被打断或者被干扰, 导致原执行流不能以预期的效果体现。

凡事三省吾身: 是不是也可以这么理解:这段代码是线程不安全的,没匹配cpu调度线程的特征

故线程不安全是“因”, 编程上“有线程同步的方式”来应对此事, 但是线程同步不仅仅是为了对应线程安全, 有一部分线程同步的功能是为了同步协调, 比如ManualResetEvent AutoResetEvent。

解决思路

  • Re-entrancy
  • Thread-local storage
  • Immutable objects
  • Mutual exclusion
  • Atomic operations

https://en.wikipedia.org/wiki/Thread_safety

上面场景提到的几个思路都是 mutual exclusion。

posted @ 2021-09-14 15:22  博客猿马甲哥  阅读(758)  评论(0编辑  收藏  举报