C#学习笔记 线程同步

来源:互联网 发布:1390清零软件中文版 编辑:程序博客网 时间:2024/06/06 04:10

多个线程同时操作一个数据的话,可能会发生数据的错误。这个时候就需要进行线程同步了。线程同步可以使用多种方法来进行。下面来逐一说明。本文参考了《CLR via C#》中关于线程同步的很多内容。

用户模式同步

易变构造

当对32位及32位以下变量的读写时,CLR保证读写操作是原子性的。也就是说bool、char、int等类型的变量可以一次性读取或者写入。但是对于long、ulong这些64位数据类型来说,就有可能不是原子操作。此外,由于编译器优化的存在,这些语句执行的顺序可能和编写代码时的顺序不同。这样的话,在多线程的环境下就有可能会出现同步问题。鉴于此,FCL提供了Volatile类,用来控制变量的读写和编译器的优化,这样的访问称为易变访问。

Volatile是一个静态类,包含了对于各种基元数据类型已经泛型类型的Write和Read方法。使用这些方法,可以做到:

  • 禁止编译器进行任何优化,对变量进行原子操作,只有在调用读写方法的时候才将值读取或写入。
  • 插入内存屏障,按照编码顺序,Write方法之前的存取操作必须在调用Write方法之前完成,Read方法之后的存取操作必须在调用Read方法之后完成。

这里有一个例子,在使用发布模式运行(非调试)程序的时候,代码行为会发生变化,导致死循环。原因如下:编译器发现在Work方法中,continue的值并没有发生改变,所以会将代码优化为在while循环之前求值,然后每次循环直接使用这个值。所以,优化后的代码while循环会直接变成死循环。从而导致程序出现问题。这也告诫我们:在多线程的环境下,对于程序应该引起足够的重视,有可能出现一些正式发布时才会出现的问题。

//当启用发布模式时,该类的行为会发生变化//由于优化导致程序进入死循环static class ProblemA{    //添加volatile关键字即可保证正确    static bool continued = true;    public static void ShowProblem()    {        Console.WriteLine("运行3秒之后停止工作:");        Thread t = new Thread(Work);        t.Start();        Thread.Sleep(3000);        continued = false;        Console.WriteLine("等待工作结束...");        t.Join();        Console.WriteLine("--------------------------------");    }    private static void Work(object state)    {        int n = 0;        //优化会导致这里变为死循环         while (continued)        {            n++;        }        Console.WriteLine("循环结束时n的值是" + n);    }}

C#同时还提供了volatile关键字,标记为volatile的变量,对其的所有操作都是易变操作。但是由于只有少数情况下才需要进行易变读写,直接标记volatile关键字会影响性能。所以还是推荐使用Volatile静态类,只有在需要的时候才进行易变操作。

互锁构造

Volatile类只有Write和Read两个方法。而Interlocked类增加了更多的方法,并且所有方法都是原子操作并提供内存屏障。

下面的例子说明了Interlocked类的主要方法。Interlocked类在多线程环境下十分有用。注意Exchange和CompareExchange方法返回的都是交换之前的值。

public static void InterlockTest(){    int i = 5;    Console.WriteLine("演示Interlocked的方法:");    Console.WriteLine("增加一个值:");    Console.WriteLine("增加之后的值:" + Interlocked.Increment(ref i));    Console.WriteLine("减少一个值:");    Console.WriteLine("减少之后的值:" + Interlocked.Decrement(ref i));    Console.WriteLine("用一个值交换:");    Console.WriteLine("交换之前的值:" + Interlocked.Exchange(ref i, 10));    Console.WriteLine("交换之后的值:" + i);    Console.WriteLine("条件交换一个值,如果值和第三个参数相等,就用第二个值交换");    Console.WriteLine("交换之前的值:" + Interlocked.CompareExchange(ref i, 10, 5));    Console.WriteLine("交换之后的值:" + i);    Console.WriteLine("--------------------------------");}

简单的自旋锁

利用上面所提到的类,就可以构造用户模式下的自旋锁了。在多个线程同时竞争一个资源的时候,只有一个才能进入,其他的线程必须空转等待。注意在空转的时候仍然会占用CPU资源,所以这种锁称位活锁。只有当访问临界资源非常快的时候才应该使用自旋锁。

internal class MySpinLock{    private int _isUsing;    public void Enter()    {        while (true)        {            if (Interlocked.Exchange(ref _isUsing, 1) == 0)            {                return;            }        }    }    public void Leave()    {        Volatile.Write(ref _isUsing, 0);    }}

内核模式同步

除了用户模式下的同步之外,还有内核模式下的同步。使用这种同步的时候,代码会从用户模式切换到内核模式,同步完毕之后在重新转换到用户模式,因此开销比较大。但是也有很多优点:

  • 内核模式下,内核可以了解到线程的状态,因而可以阻塞暂时不可用的线程,释放CPU,防止自旋。
  • 内核模式可以同步同一机器不同进程之间的线程。
  • 内核模式可以应用安全设置,防止未授权的访问。
  • 内核模式阻塞的线程可以指定超时值,时间内访问不到资源可以解除阻塞,执行其他任务。

Event构造

事件就是由内核维护的布尔型变量。为假的时候,在事件上等待的线程就会阻塞;为真的时候就会解除阻塞。事件分为两种,自动重置事件和手动重置事件。当自动重置事件唤醒一个等待的线程的时候,它会自动重置回false,让其他线程继续等待。而手动重置线程会唤醒所有线程,直到你手动将其重置回假。

Event构造的主要方法如下:

  • Set方法,将条件置为真,并唤醒一个或多个线程。
  • Reset方法,将条件置为假,阻塞其他线程。
  • WaitOne方法,在event上等待,还有接受一个超时值的重载版本。

下面是一个例子,运行可以发现自动重置事件调用Set方法之后,只有一个线程被唤醒,而手动重置事件调用Set方法之后两个线程都被唤醒。

public static void ResetEventTest(){    Console.WriteLine("两种重置事件的比较:");    Console.WriteLine("使用自动重置事件唤醒线程:");    var autoResetEvent = new AutoResetEvent(false);    Task t1 = new Task(() =>      {          autoResetEvent.WaitOne();          Console.WriteLine("任务1已经唤醒");      });    Task t2 = new Task(() =>    {        autoResetEvent.WaitOne();        Console.WriteLine("任务2已经唤醒");    });    t1.Start();    t2.Start();    autoResetEvent.Set();    Task.WhenAny(t1, t2).Wait();    Console.WriteLine("使用手动重置事件唤醒线程:");    var manualResetEvent = new ManualResetEvent(false);    Task t3 = new Task(() =>    {        manualResetEvent.WaitOne();        Console.WriteLine("任务3已经唤醒");    });    Task t4 = new Task(() =>    {        manualResetEvent.WaitOne();        Console.WriteLine("任务4已经唤醒");    });    t3.Start();    t4.Start();    manualResetEvent.Set();    Task.WhenAll(t3, t4).Wait();    Console.WriteLine("--------------------------------");}

Semaphore构造

信号量是由内核维护的int变量,表示的是临界资源的数目。当信号量为0是表示所有资源都被占用,线程被阻塞。当信号量大于0的时候,解除阻塞,并根据对应的资源使用情况减少信号量的值。信号量含有一个最大资源数目,如果释放操作导致信号量超过最大计数,会抛出SemaphoreFullException。

信号量的主要方法如下:

  • WaitOne方法,在信号量上等待,如果信号量大于0,将减少一个信号量;如果信号量为0将阻塞当前线程。另外还有一个指定超时值的重载版本。
  • Release方法,释放线程所占用的信号量,将信号量增加1并唤醒其他线程。还有一个指定同时释放多个信号量的重载版本。

下面是使用信号量实现的一个线程安全的计数器:

internal class SemaphoreCounter : AbstractCounter{    private Semaphore _semaphore = new Semaphore(1, 1);    public override int Count    {        get { return _count; }    }    public override void Increment()    {        _semaphore.WaitOne();        _count++;        _semaphore.Release();    }    public override void Decrement()    {        _semaphore.WaitOne();        _count--;        _semaphore.Release();    }}

这个计数器的抽象基类如下:

internal abstract class AbstractCounter{    protected int _count;    public abstract int Count { get; }    public abstract void Increment();    public abstract void Decrement();}

Mutex构造

互斥锁的工作方式和AutoResetEvent或者最大值指定为1的信号量相似,三者都是一次只唤醒一个等待的线程。不过,互斥锁还会记录锁定的线程ID,防止其他线程释放锁;另外,互斥锁还允许递归锁,拥有了该互斥锁的线程还可以继续上锁。不过相应的,互斥锁的速度更慢。

互斥锁的重要方法如下:

  • WaitOne方法,给互斥锁上锁,只允许获得锁的线程访问。另外还有一个指定超时值的重载版本。
  • ReleaseMutex方法,释放锁,只能由获得锁的线程调用。

下面是利用互斥锁实现的线程安全计数器:

internal class MutexCounter : AbstractCounter{    private Mutex _mutex = new Mutex();    public override int Count    {        get { return _count; }    }    public override void Increment()    {        _mutex.WaitOne();        _count++;        _mutex.ReleaseMutex();    }    public override void Decrement()    {        _mutex.WaitOne();        _count--;        _mutex.ReleaseMutex();    }}

混合模式同步

用户模式同步没有用户代码和内核代码之间的切换,但是会造成空转,白白浪费CPU;内核模式同步可以阻塞线程避免空转,但是两种模式之间的开销比较大。因此,就有了混合模式同步构造,集合了这两者的优点。

一个简单的混合锁

这个混合锁使用一个int变量和自动重置事件来实现。当没有竞争的时候,锁只对int变量进行操作,速度很快。只有当发生竞争的时候才会阻塞线程。此时不会对程序的性能造成大的影响,因为这个时候线程本来就要停止了,进行用户模式和内核模式的切换的开销相对来说就比较小了。

internal class MyHibridLock : IDisposable{    private int _isUsing;    private AutoResetEvent _lock = new AutoResetEvent(false);    public void Enter()    {        if (Interlocked.Increment(ref _isUsing) == 1)        {            return;        }        _lock.WaitOne();    }    public void Leave()    {        if (Interlocked.Decrement(ref _isUsing) == 0)        {            return;        }        _lock.Set();    }    public void Dispose()    {        _lock.Dispose();    }}

ManualResetEventSlim和SemaphoreSlim

这两个类是ManualResetEvent和Semaphore类对应的混合模式构造。使用方法也差不多,这里就不在详细说明了。

Monitor类和lock语句

Monitor类是一个支持自旋、线程所有权和递归的混合模式互斥锁。调用其Enter方法锁定一个对象,调用Exit释放一个对象。在进行这些操作的时候需要注意,尽可能的减小需要锁定的对象。这个类在使用的时候有一些注意事项,详细的原因可以参考《CLR via C#》这本书,里面有很详尽的解释。

  • 不要向Monitor传递代理对象,Monitor会锁定代理对象而不是实际对象。
  • 不要向Monitor传递类型对象的引用。
  • 不要向Monitor传递string的引用。由于字符串留用的问题,可能导致两个不相关的线程在不知情的情况下进行同步。
  • 不要向Monitor传递值类型。由于值类型会被装箱,而每次装箱的对象都不同,会导致线程完全无法同步。

由于通过调用Monitor.Enter(object)方法锁定一个对象,然后进行某些操作,再调用Monitor.Exit(object)方法释放对象这种模式很普遍,因此C#提供了lock语句,封装了这种操作。

ReaderWriterLockSlim

这个类封装了读者写者问题的处理逻辑:

  • 当一个写者线程写入数据的时候,其他所有线程都被阻塞。
  • 当一个读者线程读取数据的时候,允许其他读者线程读取数据,写者线程被阻塞。

这个类比较重要的方法如下:

  • EnterReadLock方法,进入读取锁定状态。
  • EnterWriteLock方法,进入写入锁定状态。
  • TryEnterReadLock方法,尝试进入读取锁定状态,不成功则返回false。
  • TryEnterWriteLock方法,尝试进入写入锁定状态,不成功则返回false。
  • ExitReadLock方法,退出读取锁定状态。
  • ExitWriteLock方法,退出写入锁定状态。

CountDownEvent

这个类当其中的计数大于0的时候会阻塞在其上等待的线程,而当计数为0的时候会唤醒线程。可以看出,这个类的行为和信号量恰好相反。

这个类的重要方法如下:

  • AddCount方法。计数加1,当计数为0的时候尝试加1会抛出异常。也有一个重载版本可以同时增加多个计数。
  • TryAddCount方法。同上,但是用返回值表示是否成功。
  • Reset方法。将计数重置为构造函数中指定的值。也有一个重载版本指定其它的值。
  • Signal方法。向CountDownEvent发送信号,并将计数减1。也有一个重载版本同时减小多个计数 。
  • Wait方法。阻塞当前线程。当计数为0的时候唤醒线程。也有重载版本指定超时值和取消。

下面是这个类的简单使用例子。有一群学生和一个老师,当所有学生做完作业之后,老师开始批改作业:

public static void Problem5(){    Console.WriteLine("所有学生做完作业之后,老师才能开始批改作业:");    int studentsNo = 5;    var count = new CountdownEvent(studentsNo);    var students = new Student[studentsNo];    var dowork = new Task[studentsNo];    var teacher = new Teacher(count);    for (int i = 0; i < studentsNo; ++i)    {        students[i] = new Student(count, i + 1);        int no = i;        dowork[i] = Task.Run(() => students[no].FinishWork());    }    teacher.CheckWork();    Task.WhenAll(dowork).Wait();    Console.WriteLine("--------------------------------");}public class Teacher{    private CountdownEvent _count;    public Teacher(CountdownEvent count)    {        _count = count;    }    public void CheckWork()    {        _count.Wait();        Console.WriteLine("老师开始批改作业");    }}public class Student{    private CountdownEvent _count;    private int _no;    public Student(CountdownEvent count, int no)    {        _count = count;        _no = no;    }    public void FinishWork()    {        //如果输出语句放到下面,可能会出现在输出学生完成作业之前        //老师开始批改作业的情况        Console.WriteLine($"学生{_no}做完了作业");        _count.Signal();    }}

并发集合类

对于某些问题,直接使用并发集合来进行同步可能更简单。FCL有4个线程安全的类,在System.Collections.Concurrent命名空间下定义的:

类名 作用 ConcurrentQueue 线程安全的队列 ConcurrentStack 线程安全的栈 ConcurrentDictionary 线程安全的字典 ConcurrentBag 线程安全的无序集

它们的详细用法请参考MSDN。

0 0
原创粉丝点击