回顾
前面一篇简单的介绍了TPL,以及一个最简单的例子。这一篇,来讨论一下线程安全的问题。
TPL不需要考虑线程安全?
好吧,我不知道为什么会有人提出这样的想法,首先要确定的是在MS的开发人员是人,不是神,不可能让一个类库聪明到这样的程度。要是真的有这么聪明的话,估计我们都可以转行做需求分析了,更不不需要写代码的人了。
如何证明考虑线程安全是必要的哪?来一个简单的示例就可以了:
const int count = 100000;
int value = 0;
Action action = ()=>
{
for (int i = 0; i < count; i++)
value++;
};
Parallel.Invoke(action, action, action, action);
Console.WriteLine(value);
Contract.Assert(value == count * 4, "Not thread safe at all!");
由于写这篇文章的时候用的是4核心的机器,所以启动了4个action,做++操作,如果TPL自动保证对value的访问是线程安全的,那么value的结果必然是count*4,这里,直接用Contract来验证这一点,运行起来看到什么?
这个value只有254726,明显比预期的值小了近一半。是Action偷懒了吗?好吧,做一个小修改:
const int count = 100000;
int value = 0;
Action action = ()=>
{
for (int i = 0; i < count; i++)
Interlocked.Increment(ref value);
};
Parallel.Invoke(action, action, action, action);
Console.WriteLine(value);
Contract.Assert(value == count * 4, "Not thread safe at all!");
再看看运行结果:
仅仅是使用一个线程安全的Interlocked.Increment方法代替++,就可以得到一个正确的结果,这足以证明,TPL并不会职能的帮助我们完成线程安全的工作,TPL仅仅负责安排任务并执行,并不关心这些任务是否用到了那些临界资源。
所以,不要以为使用了TPL就不需要考虑线程安全了,恰恰相反,TPL是基于每个Task的实现本身是线程安全的基础上的。
那TPL能干了些什么?
看到这里,一个疑问必定会出来,TPL不管线程安全,那它能为我们带来什么哪?
看看上面的例子,线程安全的Interlocked.Increment方法固然很好,但是,这个方法肯定没有直接++来的快,那么如果每个线程在运行中都自己保留一份本地value,到最后执行完成时,将所有的本地value(线程安全的)合并到全局value岂不是代价更小?(这里认为加锁的消耗远大于委托的消耗)
于是,改造原来的代码,增加一个运行时间:
const int count = 100000;
int value = 0;
Stopwatch sw = Stopwatch.StartNew();
Action action = () =>
{
for (int i = 0; i < count; i++)
Interlocked.Increment(ref value);
};
Parallel.Invoke(action, action, action, action);
sw.Stop();
Console.WriteLine(sw.ElapsedMilliseconds + "ms");
Console.WriteLine(value);
Contract.Assert(value == count * 4, "Not thread safe at all!");
然后再加入一个刚才说的保留线程本地value的实现:
const int count = 100000;
int value = 0;
Stopwatch sw = Stopwatch.StartNew();
Parallel.For(0, count * 4, () => 0,
(i, state, local) => local + 1,
local => Interlocked.Add(ref value, local));
sw.Stop();
Console.WriteLine(sw.ElapsedMilliseconds + "ms");
Console.WriteLine(value);
Contract.Assert(value == count * 4, "Not thread safe at all!");
这里的local变量,都存放着一个线程本地的value,因为是每个线程有自己的值,所以直接使用最简单的+1操作,而在将local添加到全局的value中时,全局的value会被多个线程操作,所以需要使用线程安全的Interlocked.Add方法。
看上去复杂了不少,而且对委托的Invoke操作也增加了100000倍,这段程序真的能比上面的跑得更快吗?
说真的,自己心里也没底。。。还是看看实际的结果吧:
28ms vs. 13ms,快了1倍,是不是很惊讶,不妨增加count到1000000看看结果:
差距被拉大了,接近6倍,count再加到10000000看看:
稳定在6倍附近了,所以千万不要小看同步的代价,即使最快的Interlocked.Increment方法也至少是调用Delegate的6倍。
后话
当然用Action也可以实现本地缓存:
Action action = () =>
{
int local = 0;
for (int i = 0; i < count; i++)
local++;
Interlocked.Add(ref value, local);
};
Parallel.Invoke(action, action, action, action);
并且这样的运行的运行速度是最快的,既没有很多的同步,也没有很多的委托调用。