惟有于技术中才能明得真相

留心类库的线程安全!

在最近的项目中看人使用使用一个单例SHA1Managed类来计算字节流的hash值,hash的作用在于防止用户篡改数据,这非Object.GetHashCode()方法得到的hash值,后者主要用于集合框架中。这里的问题在于使用了单例,因为SHA1Managed不是线程安全的,而在一个典型Web应用都运行在多线程环境下,在多线程环境下可能致使SHA1Managed的内部状态遭到破坏,这种破坏一旦发生便不能恢复,以后都会得到错误的运行结果。关于SHA1Managed的非线程安全性,MSDN其实是有说明的,并且是专有一栏,但如果程序员不关注线程安全的话,确实是很难注意到它的。MSDN是这样说的:

Any public static (Shared in Visual Basic) members of this type are thread safe. Any instance members are not guaranteed to be thread safe.
光说无用,我们可以写程序来验证一下:


using System.Threading;
using System.Security.Cryptography;
using System.Text;

public class MyClass
{
    private static readonly HashAlgorithm alg = new SHA1Managed();
    private static readonly byte[] data = createBytes(10000);
    private static readonly byte[] expectedHash = alg.ComputeHash(data);
   
    private static byte[] createBytes(int len) {
        byte[] bytes = new byte[len];
        for (int i = 0; i < len; i++) {
            bytes[i] = (byte)i;
        }
        return bytes;
    }
   
    public static void Main()
        Thread[] threads = new Thread[100];
        for (int i = 0; i < threads.Length; i++) {
            threads[i] = new Thread(ThreadMain);
        }
        for (int i = 0; i < threads.Length; i++) {
            threads[i].Start();
        }
        for (int i = 0; i < threads.Length; i++) {
            threads[i].Join();
        }
        Console.WriteLine("End All Threads");
    }

    private static bool Equals(byte[] bytes1, byte[] bytes2) {
        if (bytes1.Length != bytes2.Length) return false;
       
        for (int i = 0; i < bytes1.Length; i++) {
            if (bytes1[i] != bytes2[i]) return false;
        }
        return true;
    }
   
    static void ThreadMain() {
        for (int i = 0; i < 1000; i++) {
            byte[] hashedBytes = alg.ComputeHash(data);
            if (!Equals(expectedHash, hashedBytes)) {
    throw new Exception("thread safe vilotation");
            }
        }
    }
}


该类中首先创建的SHA1Managed一个单例,一个大小为10000的字节数组,然后计算它的hash值,在多线程环境中计算出来的hash值会与这个hash值进行比较,如果不同则表明
SHA1Managed的确不能在多线程环境中共享。在Main方法中同时启动了100个线程,每个线程调用SHA1Managed计算hash值100次,如果检测到计算错误,则抛出异常,程序退出。我在公司运行的结果是会出错的(这里没有贴出运行结果主要在于家里没有装.NET),这也就证实了SHA1Managed是非线程安全的。将字节数组的大小的改成1000,运行并不会出错,但这也并不能证明SHA1Managed对小数据就是线程安全的,只是说明对很小数据很难检测线程问题。其实不仅SHA1Managed是非线程安全的,在Cryptography命名空间下的大多数类都是非线程安全的,因此也不能在多线程环境中共享实例。要解决这个问题也很简单,就是不要共享实例,每个线程使用单独的实例,这对SHA1Managed不成问题,因为创建SHA1Managed对象的开销很小。之所以程序员会犯这样的错误,主要是想当然地认为SHA1Managed是不可变对象,从它的行为来看,它的确很像不可变对象,并且从理论上说也的确可以做到,但事实上它不是,这就是灾难的开始。在Java中也有一个很容易犯类似错误的类,那就是DateFormat,大概很少有程序员注意到它不是线程安全的(我也曾看到过项目中有在Util类使用DateFormat单例的),这在文档中也有详细说明。

这两个类没有做成线程安全,从理论上来说他们也可以做到线程安全,那是不是API的设计者的失误呢?设计者不将它们做成线程安全的,当然是有他们的原因的。这两个类的共同特点就是算法比较复杂,在计算过程中很可能涉及到许多中间变量,它们在方法之间也会用到,如果设计成线程安全的,就需要在方法之间将传递这些参数,这使得程序实现起来很麻烦,不仅要传递这么参数,并且每添加删除一个中间变量,都会涉及很多函数的修改,不如将它们都放在实例变量中,这样就不用在方法之间来回传递这些参数了,带来的后果就是多线程不再安全了。另外一个原因是,像这样复杂的算法有很多是从C语言中移植过来的,而C语言是没有考虑多线程安全的。

这样违反直觉的设计的确给程序员带来一些麻烦,尽管这样的例子不多,我们平时也要留心它们,因为这样的问题一旦发生就很难检测。

0 评论: