在 dotnet 的最佳实践里面,不推荐在静态构造函数里面包含复杂的逻辑,其中也就包含了本文聊的和多线程相关的锁的使用。最佳做法是尽量不要在静态构造函数里面碰到任何和锁以及多线程安全相关的逻辑。本文来告诉大家,在静态构造函数里面使用锁将带来的问题以及原因
在 .NET 的设计里面,一个类型的静态构造函数,是在此类型第一次被碰到时将会被 CLR 调用。调用的时候,只允许一个线程执行进入静态构造函数,换句话说是一个类型的静态构造函数不会重复被多个线程执行,只会被执行一次。如此即可保证静态构造函数的安全性
不同于实例构造函数,实例构造函数大部分由代码里面的 new 关键词触发,执行代码的仅有一个线程。如果多个线程调用 new 关键词那么将创建出来不同的实例,分别引用不同的内存空间。如以下代码
如果有多个线程同时进入,调用到 new Foo()
这句代码,自然是创建出多个不同的实例。这就意味着无论是静态构造函数还是实例构造函数,都是只能被一个线程执行。当然,这是有例外的,由于在 .NET 里面,无论是静态构造函数还是实例构造函数,都是一个函数方法,通过反射,依然可以当成基础的方法调用,因此在使用反射时,以上的说法是不成立的
在不使用反射的黑科技下,保持让构造函数只能由一个线程执行,可以解决十分多的线程同步安全问题
对于实例的构造函数只能由一个线程执行这个十分好理解。由于进入代码里面,不同的线程将会创建出不同的对象,每个对象都有自己的独立的内存空间,独立的内存空间里面执行的实例构造函数执行的过程参数以及字段等都是独立的。实际有两个线程同时调用 new Foo()
代码,两个线程所使用的实例构造函数也是不同的,例如构造函数里面使用的过程参数 this.
的 this
就分别属于不同的两个对象
然而静态构造函数就比较复杂起来的,大家都知道,在没有标记线程静态的前提下,所有的静态字段和属性等都是全局共享的,全局共享的就意味着所有的线程都访问到的相同的对象
如上文所说,一个类型的静态构造函数将在类型第一次被碰到时被 CLR 调用,那如何了解当前是第一次碰到?如果有两个线程同时都碰到呢,此时由哪个线程执行,还是两个线程都要执行?
在静态构造函数被多个线程碰到时,相当于进入了资源竞争,无论是多少个线程同时碰到某个类型,此类型的静态构造函数只能由其中的一个线程执行,而其他线程进入等待过程。相当于进入静态构造函数时设置了一个锁对象,只有一个线程能进入调用静态构造函数,其他线程只能等待静态构造函数执行完成才能继续
多线程在碰到某个类型的静态构造函数时,就和碰到竞态资源一样,也相当于碰到一个锁
然而静态构造函数的多线程安全问题可比其他的竞态资源更加复杂,原因也如上文描述,一个类型的静态构造函数是在这个类型第一次被碰到的时候触发。然而代码里面什么时候是第一次碰到,这个是非常复杂且不可控的,而且也会随着代码的迭代而被变更的。例如当前是十分确定有某个函数碰到了某个类型,然而很快就会因为函数之前的调用顺序变更,从而变更了静态构造函数的初始化时机。或者在代码迭代时,在新的时机更快碰到了某个类型,从而触发了类型的静态构造函数
没有开发者会在写代码的时候,想到碰到某个类型时,需要关注此类型的静态构造函数的初始化时机是否被更改,从而导致了问题。如果真的如此关注了,那代码也写不了了,碰到的每一个类型,都需要关注一下的话,这个开发就不好玩了
这就是为什么最佳实践里面推荐不要在静态构造函数里面放复杂的逻辑,推荐只是做一些简单的初始化逻辑。如此能很大解决因为静态构造函数的时机问题导致的问题,无论什么时候碰到静态构造函数,如果静态构造函数只是做非常简单的和无依赖的逻辑,那自然是没有什么问题
而如果是如本文要聊的,在类型的静态构造函数里面,碰到了锁,那这个故事就开始复杂起来了
无论是什么语言,只要还是在图灵的体系下,只要在玩多线程,那么锁和原子和事务是少不了的。不过这是一个很大的话题,本文只来和大家聊锁与静态构造函数。在使用锁的时候,能带来的优势是提供了一个解决多线程安全问题的方法,带来的问题是多线程安全问题。没错锁是一个会导致的线程安全问题的解决多线程问题的方法,是否会导致问题,完全取决于如何使用。锁不是一个完美的解决方案,如果使用不当,那带来的线程安全问题将会有很多,而且锁的使用注意点也非常多,这就是为什么会有本文的核心原因
在使用锁的最佳实践里面,就有确定性的说法。也就是说何时捕获锁、等待锁,以及合适释放锁都应该是确定的,而不能是不确定的行为,否则轻的话就是线程不安全,资源被意外抢入,重的话就是无限线程互等,应用进入摸鱼状态,啥都不做都在等着锁,或者应用拉满了计算资源疯狂执行
在静态构造函数里面使用锁将违背锁的最佳实践里面的确定性调用这一条,静态构造函数是在类型第一次碰到时被触发,也就是开发者是无法确定静态构造函数合适被调用的。再加上一些代码优化和内联,将会导致调试下和发布下的行为也会不同。再加上代码迭代,静态构造函数的触发时机也是很难进行控制的。在静态构造函数里面使用锁将是一个危险的行为,即使当前版本在调试下是能符合预期工作的,然而在发布的时候,在某些用户的设备上,也许就会遇到奇怪的问题。如果想要提升产品的代码质量,就需要尽量不要在静态构造函数里面使用锁的相关方法,包括直接或间接的调用到锁
举一个例子来告诉大家在静态构造函数里面调用锁的相关方法导致的多线程互等的问题
假设在 Foo 类型的静态构造函数里面需要使用到一个叫 LockObject
对象的锁,而这个 LockObject
对象的锁是有多个类型在调用的,定义代码如下
此时有 Foo1 类型,在静态构造函数调用了 Foo2 的 Do 方法,代码如下
以上代码在 Foo1 被第一次碰到的过程中,可能会存在多线程相互等待,例如调用代码如下
运行代码可以看到 task1 和 task2 在互等,点击暂停,可以看到 task1 和 task2 对应的线程的线程号分别是 9764 和 22044 两个。其调用堆栈分别如下
线程号是 9764 的 task1 的调用堆栈如下
线程号是 22044 的 task2 的调用堆栈如下
也就是说 task1 在尝试拿到 Foo1 的 Number 属性,需要先等待 Foo1 的静态构造函数执行完成。然而 Foo1 的静态构造函数是在 task2 对应的线程执行,而 Foo1 的静态构造函数碰到的 Foo2 的 LockObject
对象的锁被 task1 对应的线程获取。因此想要让 Foo1 的静态构造函数能继续执行,就需要等待 task1 线程释放锁对象。然而 task1 要释放锁对象的前提是能获取完成 Foo1 的 Number 属性。但是获取 Foo1 的 Number 属性需要等待在 task2 上执行的 Foo1 的静态构造函数执行完成
也就是说在 task1 上执行的代码,需要等待 task2 执行完成,才能释放锁。在 task2 上执行的代码,需要等待 task1 释放锁才能执行完成。完美让两个线程进入互等
这就是其中的一个线程不安全的例子。如果将 task1 里面的 Thread.Sleep
去掉,那才是可怕。因为运行代码,将会发现有时存在线程互等,有时不存在。如果这是发给用户端执行的应用,那将会有用户反馈说为什么有时候应用就啥也不干了,但有时又跑得好好的,说不定这时客服小姐姐的重启搞定一切的大法就能解决这个问题。但是如果刚好是大佬用户遇到了,要求开发者一定要解决,那预计开发者想要复现这个问题,也是很不好玩的,如果进入以上方法的步骤比较多,那大概可以连续多加几天的班,如果再加上逻辑稍微复杂,加班的时候自己不清醒,那预计还是解决不了的
保持静态构造函数的简单,可以解决大量的问题。不要在静态构造函数里面添加复杂的代码,如果真的有这个需求,将这些复杂的代码放在一个静态函数里面,自己寻找合适的时机调用
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。 欢迎转载、使用、重新发布,但务必保留文章署名 林德熙 (包含链接: https://blog.lindexi.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请与我 联系。