C#学习笔记之线程安全

8/3/2015来源:C#应用人气:1340

C#学习笔记之线程安全

线程安全


一个程序和方法在面对任何多线程情况下都没有不确定,那么就是线程安全的. 线程安全主要通过加锁和减少线程之间互动的可能性来实现。

通用类型很少全面线程安全的,由于以下几个原因:

  • 线程安全的开发负担非常重,尤其一个类型有很多字段时(每一个字段潜在与很多线程交互)。
  • 线程安全会降低了性能(正确的说,看它是否在多线程中使用)。
  • 线程安全不一定非要使用线程安全类型。

因此,线程安全仅在需要的地方实现。

然而,有一些方法来“欺骗”和有大而复杂的类安全运行在多线程环境下。其中一个是通过封装大片代码牺牲粒度--甚至访问完整的对象--在一个排斥锁中,强迫在高层串行化访问它。实际上,这种策略,在使用第三方不安全代码时是非常关键的(大多数框架类型)。这种技巧简单的使用一个相同的锁来保护一个不线程安全对象的所有字段,属性和方法。如果这个对象的方法执行的非常快,那么这种解决方案工作的非常好(否则,将大量阻塞)。

原始类型之外,很少有框架类是线程安全的,这个保证应该是开发者的责任,通常使用排斥锁来实现。

另外一种方法是通过最小化共享数据来最少化线程之间的互动。这是一个非常好的方法,常被用在无状态的中间层或Web服务页面。因为多个客户端请求能够同时到达,因此服务方法必须是线程安全的。一个无状态的设计(由于可扩展性非常受欢迎)内在限制了互动的可能性,因为类在请求之间不保持数据。线程互动仅在用于创建的静态字段,用于在内存中缓存常用的数据和提供基本服务,如授权和审计。

最后一种方法是使用原子锁。.Net框架就是这样实现的,如果你子类化ContextBoundObject并使用Synchronization属性到类上。那么该对象的属性和方法无论在什么时候被调用,方法和属性的执行期间都会采取一个对象的原子锁。尽管,这减少了线程安全的负担,但它也带来了自己的问题:死锁不发生,可怜的并发性及意外的可重入性。由于这些原因,手动锁是更好的选择--至少在一个简单的自动锁能被使用之前。

线程安全和.NET框架类型

锁可以使得非线程安全代码变得线程安全。.NET框架就是一个好的应用程序:几乎所有非原始类型在实例化时都不是线程安全的(仅只读的时候是安全的),然而它们能被用在多线程代码中,如果通过一个锁来保护对它的访问。下面是一个例子,两个线程同时添加一个Item到同一个List中,然后枚举这个list:

SafeThreadExample

这个例子中锁住了list本身。如果有两个相关的list,那么不得不选择在一个通用的对象上加锁(更好的办法是使用一个独立的字段)。

枚举.NET集合也是线程安全的,如果在枚举期间list被修改,那么将抛出一个异常。在这个例子中,拷贝item到一个数组中,而不是在枚举期间锁住list。如果枚举过程非常耗时间,这避免了过分拥有锁(另外的解决方案是用读写锁)。

围绕对象加锁

有时你需要围绕对象加锁。想象一下,假设.NET的List是线程安全的,我们想要添加一个item到list:

if(!_list.Contains(newItem))_list.Add(newItem);

不管这个list是否线程安全,语句本身并不是线程安全的。为了防止在测试list是否包含新项和添加新项之间被其它线程抢占,整个if语句必须放在lock中。同样的锁也需要放在list被修改的任何地方。如下面的句子也需要放在锁里:_list.Clear();为了确保不被抢占。换句话说,我们不得不把它作为一个线程不安全的集合类加锁(这使得假设list是线程安全是多余的)。

围绕访问集合对象加锁使得在高并发环境中过分阻塞。到目前为止,4.0框架提供了一个线程安全queue,stack和dictionary。

静态方法

通过一个自定义锁来封装对一个对象的访问,只有在所有并发线程意识到--并且使用--锁来实现。如果一个对象的socpe很广,那么事实并不总是这样的。最糟的情况是一个public类型含有static成员。想象一下,如果DateTime结构体的静态字段DateTime.Now,不是线程安全的,两个并发调用将导致混乱输出或一个异常。唯一的方法是外部的锁来锁住类型的本身--lock(typeof(DateTime))--在调用DateTime.Now之前。如果程序员都同意这样做,那么没有问题(事实这不太可能)。而且,锁住类型本身也带来了自己的问题。

由于这个原因,静态成员必须小心的编程来满足线程安全。.NET框架中常见的设计是:静态成员是现存安全的,实例化成员不是线程安全的。根据这个模式当写访问public类型时,以便不制造线程安全难题是有意义的。换句话说,通过使静态函数线程安全,你正在编程以免不妨碍对它的使用。

静态函数的线程安全并不是它本身的优点而是需要你显式地来编写代码。

只读线程安全

使得类型对于只读访问线程安全(是可能的)是有优势的,因为它意味着使用它而不必过度加锁。一些.NET类型遵循了这个原则:如,集合是对于并发读是线程安全的。

遵循这个原则很简单:如果你写一个文档记录一个类型对于并发读是类型安全的,那么在函数体内不需要写,使用者会预期它是线程安全的。如,在一个集合的ToArray()函数实现中,你可能通过压缩内部结构来实现。然而,这将对使用者来说预期只读是线程安全的。

只读线程安全是一个枚举器和可以枚举分割的一个原因:2个线程能同时枚举一个集合,因为每一个都有一个独立的枚举器对象。

由于文档缺失,你需要额外小心一个函数是否只读线程安全。如Random类:当你调用Random.Next()时,它内部实现要求更新私有的种子值。因此,你必须围绕Random类加锁,或每个线程一个独立的对象。

服务器应用程序的线程安全

服务器应用程序需要多个线程来处理多个并发的客户端。WCF, asp.net和Web Services应用程序就是明显的例子;使用HTTP或TCP的Remoting服务器应用程序也是。这意味着你编写服务器段代码,你必须考虑线程安全,如果在处理客户端请求的线程之间有任何可能的互动。幸运的是,这种可能性很小;一个典型的服务器类是无状态的(没有字段)或有一个活动模型为每一个请求创建一个独立的对象模型。互动经常通过静态字段来触发,有时使用缓存来改善性能。

据个例子,假设你有一个RetrieveUser方法去查询数据库

//User is a custom class with fields for user datainternal User RetrieveUser(int id){...}

如果这个方法经常被调用,你应该通过缓存数据在一个静态的Dictionary来改善性能。这是一个考虑了线程安全的解决方案:

static class UserCache{    static Dictionary<int,User> _users = new Dictionary<int,User>();    internal static User GetUser(int id)    {        User u = null;        lock(_user)        {            if(_user.TryGetValue(id, out u)) return u;        }                u=RetrieveUser(id);    ///Method to retrieve from database.        lock(_users)_users[id]=u;        return u;    }}

我们必须最低限度用锁来读写或更新字典确保它的线程安全。这个例子中,我们在性能和简单之间作了一个折中。我们的设计实际上有非常小的可能潜在低效率:如果2个线程同时调用这个函数并带有相同的没有找到的id,那么RetrieveUser可能被调用2次--字典将被不必要的更新。如果一个锁跨过整个函数,那么这种情况将不会发生,但是创建了一个更糟糕的低效:整个cache将在调用RetrieverUser期间被锁住,其它线程将被阻塞不管你是否查询其它用户。

富客户端和线程相关性

不管是WPF还是Window Form库都遵循给予线程相关性的模型上。尽管,每一个都有自己独立的实现,但是他们如何工作却非常类似。

粉饰富客户端的对象主要基于WPF的依赖属性(DependencyObjec)或者Window Form的控件(control)。这些对象有线程相关性,这意味着仅实例化它们的线程才能访问它们的成员。违反这个原则,将导致不可预料的错误或抛出一个异常。

从积极的一面看,你可以不需要加锁就能访问一个UI对象。从消极的一面看,你如果想要在Y线程调用对象X的成员,你必须列集(Marshal)这个请求到线程Y。你可以显式地使用以下方法来这样做:

  • 在WPF中,在元素的Dispatcher对象上调用Invoke或者BeginInvoke。
  • 在WF中,在控件上调用Invoke或BeginInvoke。

Invoke和BeginInvoke都接受一个委托,引用你想运行的目标控件上的方法。Invoke同步工作:调用者阻塞直到函数执行完毕。BeginInvoke异步工作:调用者立即返回,请求将被压入队列(使用处理键盘,鼠标和定时器相同的消息队列)。

假设我们有个一个窗体包含了一个文本框叫txtMessage,我们想要从一个工作线程去更新它的内容,下面就是一个WPF的例子:

public partial class MyWindow : Window{    public MyWindow()    {        InitializeComponent();        new Thread(Work).Start();    }    void Work()    {        Thread.Sleep(5000);        UpdateMessage("The answer");    }    void UpdateMessage(string message)    {        Action action= ()=>txtMessage.Text=message;        Dispatcher.Invokd(action);    }}

WF的代码类似于WPF,除了调用窗体的Invoke来代替。

    void UpdateMessage(string message)    {        Action action= ()=>txtMessage.Text=message;        this.Invokd(action);    }

框架提供了2方法来简化这个流程:

  • 后台工作线程(BackgroundWorkder)
  • 任务(Task)

工作线程 VS UI线程

想一想一个富客户端有2个不同类型的线程是非常有帮助的:UI线程和工作线程。UI线程(下面称“拥有”)实例化UI元素;工作线程没有。工作线程通常执行很长时间的任务如提取数据。

大多数富客户端程序只有一个UI线程(也叫主线程)并且偶尔产生工作线程--也直接或使用后台工作线程。这些工作线程为了更新或报告进度列集返回到主线程。

那么,应用程序什么时候应该有多个UI线程呢?这主要的场景是当你想要有多个顶层窗体,经常叫SDI(Single Document Interface)程序,如Word。每一个SDI窗体通常显示本身作为一个独立的应用程序在任务栏并且大多与其它SDI窗体是孤立的。通过给定一个这样的拥有UI线程的窗体,应用程序能够做出更多的响应。

Immutable Objects(不可变得对象)

一个不可变得对象是指它的状态不能改变--不管是内部还是外部。这个不可变得字段通常声明为只读并且在构造时完全初始化。

不可变是一个功能性编程的标记--代替带有不同属性的可变对象。LINQ遵循了这个范例。不可变在多线程中也是有价值的,它避免了共享写的问题--通过消除(或最小化)共享写。

使用不可不对象的一个原则是封装一组相关的字段,去最小化加锁的周期。据一个简单的例子,假设我们有2个字段:

int _percentComplete;

string _statusMessage;

并且我们想要原子性的读写它们。我们可以使用下面的方式而不是围绕这些字段加锁:

class PRogressStatus //Represents progress of some activity{    public readonly int PercentComplete;    public readonly string StatusMessage;    // this class might have many more fields...    public ProgressStatus(int percentComplete, string statusMessage)    {        PercentComplete = percentComplete;        StatisMessage = statusMessage;    }}

然后我们定义一个那种类型的单字段,伴随锁对象:readonly object _statusLocker = new object(); ProgressStatus _status;

我们能读写那种类型的值而不需要拥有锁。

注意这是一个不需要用锁来阻止一组相关属性不一致的方法。但是这并不能在你使用时阻止它被改变--由于这个原因,你需要一个锁。在Part5,我们将看到更多的使用不变性来简化多线程的例子--包括PLINQ。