C#多线程(三)

一、线程同步概述

在多线程程序中,当存在共享变量和抢占资源的情况时,需要使用线程同步机制来防止发生这些冲突,这样才能保证得到可预见的结果,也就是线程安全的。否则就会出现不可预知的结果产生线程不安全问题。特别是在访问同一个数据的时候最为明显。主要通过以下四个方式进行:

  • 简单阻塞:让一个线程等待另一个线程执行结束或者等待一段时间而阻塞执行,使用Sleep、Join、Task.Wait这几个方式

构成

目的

Sleep

阻止给定的时间周期

Join

等待另一个线程完成

  • 锁:排他锁是最常见的锁机制,对于共享数据在每个线程内访问前判断锁的情况,保证每次只能有一个线程访问,使得相互不会干扰结果。排他锁使用lock关键字、Mutex类和SpinLock,对于共享锁使用Semaphore、SemaphoreSlim和读写锁。

构成

目的

是否跨进程

速度

lock

确保只有一个线程访问某个资源或某段代码。

Mutex

确保只有一个线程访问某个资源或某段代码。可被用于防止一个程序的多个实例同时运行。

中等

Semaphore

确保不超过指定数目的线程访问某个资源或某段代码。

中等

  • 信号量:这种机制使得线程一直阻塞知道接收到其他线程的通知才开始执行,避免了无效的轮询需求。最常见的方法是使用event wait handles和Monitors类的Wait/Pulse方法,.NET4.0 使用的是CountdownEvent和Barrier类。

构成

目的

跨进程?

速度

EventWaitHandle

允许线程等待直到它受到了另一个线程发出信号。

中等

Wait 和 Pulse*

允许一个线程等待直到自定义阻止条件得到满足。

中等

  • 非阻塞的同步机制:这个机制使用处理器的原语操作来实现。C#提供了非阻塞的原语:Thread.MemoryBarrier、Thread.VolatileRead、Thread.VolatileWrite、volatile关键字和Interlocked类。

构成

目的

跨进程?

速度

Interlocked*

完成简单的非阻止原子操作。

是(内存共享情况下)

非常快

volatile*

允许安全的非阻止在锁之外使用个别字段。

非常快

 
阻塞的线程占用处理器阻塞当时的时间片后,一直就不会占用处理器,知道发生阻塞的条件满足后才会运行。可以使用下面的方式来测试一个线程是否被阻塞:
     bool  blocked = (Thread.ThreadState & ThreadState.WaitSleepJoin) != 0;
解除阻塞发生的情况:(其中对于使用Suspend方法挂起的线程并不视为阻塞状态)
  • 阻塞条件满足
  • 执行时间完毕
  • 使用Thread.Interrupt打断
  • 使用Thread.Abort丢弃
阻止和轮询: 使用轮询非常消耗CPU资源,但是可以使用轮询休眠组合使用的方式。
二、锁和线程安全
1、lock
线程安全就是为了任何时刻只有一个线程进入临界区代码。下面是使用lock关键字来实现线程安全,这种互斥锁如果有大于一个线程竞争这个锁,他们会形成一个就绪队列,以先到先得的方式获得锁,他们被阻止在ThreadState的WaitSleepJoin状态,可以使用Interrupt和Abort来强制线程释放锁。
       static object locker = new object();
        static int val1, val2;
        static void Main(string[] args)
        {
            val1 = 100;
            val2 = 10;
            Thread t1 = new Thread(Go);
            Thread t2 = new Thread(Go);

            t1.Start();
            t2.Start();

            Console.ReadKey();
        }
        static void Go()
        {
            lock (locker)
            {
                if (val2 != 0) Console.WriteLine(val1 / val2);
                val2 = 0;
            }
        }


这里得到的是线程安全的结果,只有一个线程执行输出结果,否则可能会出现val2为0 而被作为除数的错误。lock语句实际上是调用了Monitor.Enter和Monitor.Exit方法,同时与try-finally语句配合。上述lock语句的等价方式如下:
Monitor.Enter(locker);
try
{
    if (val2 != 0)  Console.WriteLine(val1/val2);
    val2 = 0;
}
finally
{
    Monitor.Exit(locker);
}
上述使用的locker同步对象, 必须是引用类型,最好是在私有类里面定义,防止外部锁定相同对象。可使用lock(this){...}来精确控制锁的范围和粒度,同时, 锁没有阻止对同步对象本身的访问
2、嵌套
可以重复锁定相同的对象,多次调用Monitor.Enter和Monitor.Exit或者是lock来实现,但是线程只能在最开始或者最外面的锁时被阻止。
3、使用情形和性能
任何与多线程有关的会进行读和写的字段都应当加锁。
锁本身是非常快的,一般只需几十纳秒,发生阻塞的情况下也只会接近数微妙范围。对于太多同步对象死锁是非常容易出现的,因此好的原则是开始使用较少的锁。
4、Mutex
Mutex非常类似lock关键字,但是可以在多个进程间工作。使用Mutex耗时几个微秒级时间,大约是使用lock关键字耗时的50倍。使用Mutex的WaitOne来阻塞,使用ReleaseMutex方法来接触阻止。
        static void Main(string[] args)
        {
            using (var mutex = new Mutex(false, "mutex"))
            {
                if (!mutex.WaitOne(TimeSpan.FromSeconds(3), false))
                {
                    Console.WriteLine("Another app instance is running. Bye!");
                    return;
                }
                RunProgram();
            }
        }

        static void RunProgram()
        {
            Console.WriteLine("Running. Press Enter to exit");
            Console.ReadLine();
        }
5、Semaphore
类似一个房间,有最大可以容纳的人数,当人数达到最大可容纳数之后就不能进入只能排队,这时每次有一个人出房间之后,排队的一个才能进入房间。构造函数使用至少两个必须参数:一个是当前可容纳的数目,另一个是可容纳的最大总数。
当可容纳的最大总数为1的时候就和lock和Mutex是类似的,只是没有“主人”,任何在Semaphorenn内的线程都可以调用Relase方法退出,但是Mutex和lock只有获得了锁的线程才能释放它。
C#中有两个版本的类:Semaphore和SemaphoreSlim类,后者是.NET 4.0中的类,是经过优化了可以满足很低延迟的并行编程的需求。当然也可在传统多线程编程中处理归功于在等待状态时有一个cancellation token。
Semaphore在限制并发方面非常有用,可以阻止过多的线程在一段代码中一次执行,Semaphore的容量就是限制过多线程的上限数。
        static SemaphoreSlim sem = new SemaphoreSlim(3);
        static void Main(string[] args)
        {
            for (int i = 1; i < 10; i++)
                new Thread(Enter).Start(i);
            Console.ReadKey();
        }

        static void Enter(object id)
        {
            Console.WriteLine(id + "wants to enter");
            sem.Wait();
            Console.WriteLine(id + "enter in!");
            Thread.Sleep(1000 * (int)id);
            Console.WriteLine(id + "is leaving");
            sem.Release();
        }

C#多线程(三)_第1张图片
上述代码中Thread.Sleep如果替换为某些紧急的磁盘I/O读写操作,通过这样限制线程最大数目,限制并发的执行磁盘操作数目,可以大大提高程序的整体性能。

6、线程安全
线程安全的代码是指在面对任何多线程的情况下,都没有不可预知的因素或结果,首先是完成锁,其次是减少线程间交互的可能性。线程安全的开发是重要的,同时线程安全会带来性能损失。因此线程安全在需要实现的地方来实现。
  • 一种是牺牲粒度包含大段的代码——甚至在排他锁中访问全局对象,迫使在更高的级别上实现串行化访问。这一策略也很关键,让非线程安全的对象用于线程安全代码中,避免了相同的互斥锁被用于保护对在非线程安全对象的所有的属性、方法和字段的访问。
  • 另一个方式欺骗是通过最小化共享数据来最小化线程交互。这是一个很好的途径,被暗中地用于“弱状态”的中间层程序和web服务器。自多个客户端请求同时到达,每个请求来自它自己的线程(效力于ASP.NET,Web服务器或者远程体系结构),这意味着它们调用的方法一定是线程安全的。弱状态设计(因伸缩性好而流行)本质上限制了交互的能力,因此类不能够在每个请求间持久保留数据。线程交互仅限于可以被选择创建的静态字段,多半是在内存里缓存常用数据和提供基础设施服务,例如认证和审核。
7、释放
一个被阻止的线程可以通过Interrupt和Abort两个方法提前释放,但是这个操作必须通过别的活动的线程实现,等待的线程没有能力对其被阻止的状态做任何事情。使用Interrupt后会继续执行知道下一次被阻止时,抛出ThreadInterruptedException异常。但是使用Abort并不会继续执行,会在线程当前所执行的位置抛出异常。
        static SemaphoreSlim sem = new SemaphoreSlim(3);
        static void Main(string[] args)
        {
            Thread t = new Thread(delegate()
            {
                try
                {
                    Thread.Sleep(Timeout.Infinite);
                }
                catch (ThreadInterruptedException)
                {
                    Console.WriteLine("Exception!");
                }
                Console.WriteLine("Woken");
            });
            t.Start();
            t.Interrupt();
            Console.ReadKey();
        }

三、使用等待句柄通信

EventWaitHandle有两个子类AutoResetEvent和ManualResetEvent,这些类在线程间进程信号传输是非常容易的。

1、AutoResetEvent

每次允许一个线程在收到别的线程的通知后解除阻止通过一次。使用WaitOne方法等待或阻止一个线程,调用Set方法来检测是否让该线程通过执行,如果有许多线程调用WaitOne,则会形成一个队列,其他未阻塞的线程通过调用Set方法来释放一个阻塞。如果调用Set时没有线程处于等待状态,那么句柄保持打开只到某个线程调用了WaitOne方法,但是在没有等待的时候重复调用Set方法不会让多个线程通过。

WaitOne 接受一个可选的超时参数——当等待以超时结束时这个方法将返回false,WaitOne在等待整段时间里也通知离开当前的同步内容,为了避免过多的阻止发生。Reset作用是关闭旋转门,也就是无论此时是否已经set过,都将阻塞下一次WaitOne——它应该是开着的。

使用构造函数创建对象或者使用基类创建:

EventWaitHandle wh = new AutoResetEvent (false);
EventWaitHandle wh = new EventWaitHandle (false, EventResetMode.Auto);

class BasicWaitHandle
{
  static EventWaitHandle _waitHandle = new AutoResetEvent (false);
 
  static void Main()
  {
    new Thread (Waiter).Start();
    Thread.Sleep (1000);                  // Pause for a second...
    _waitHandle.Set();                    // Wake up the Waiter.
  }
 
  static void Waiter()
  {
    Console.WriteLine ("Waiting...");
    _waitHandle.WaitOne();                // Wait for notification
    Console.WriteLine ("Notified");
  }
}
C#多线程(三)_第2张图片

2、跨进程的EventWaitHandle

EventWaitHandle的构造器允许以“命名”的方式进行创建,它有能力跨多个进程。名称是个简单的字符串,可能会无意地与别的冲突!如果名字使用了,你将引用相同潜在的EventWaitHandle,除非操作系统创建一个新的。

EventWaitHandle wh = new EventWaitHandle (false, EventResetMode.Auto,
  "MyCompany.MyApp.SomeName");
如果两个程序都运行上述代码,他们就可以彼此发送信号,等待句柄可以跨越两个进程中的所有线程。

3、任务确认

假设希望在后台完成任务,但又不在每次得到任务时再创建一个新的线程。可以通过一个轮询的线程来完成:等待一个任务,执行它,然后等待下一个任务。这是一个普遍的多线程方案。也就是在创建线程上切分内务操作,任务执行被序列化,在多个工作线程和过多的资源消耗间排除潜在的不想要的操作。

        static EventWaitHandle ready = new AutoResetEvent(false);
        static EventWaitHandle go = new AutoResetEvent(false);
        static volatile string task;
        static void Main(string[] args)
        {
            new Thread(work).Start();

            for (int i = 1; i <= 5; i++)
            {
                ready.WaitOne();
                task = "#".PadRight(i, '@');
                go.Set();
            }
            ready.WaitOne();
            task = null;
            go.Set();
            Console.ReadKey();
        }

        static void work()
        {
            while (true)
            {
                ready.Set();
                go.WaitOne();
                if (task == null) return;
                Console.WriteLine(task);
            }
        }
C#多线程(三)_第3张图片

4、生成者消费者模型

还有一个普遍的线程方案是在后台工作进程从队列中分配任务,称为生产者/消费者队列:在工作线程中生产者入列任务,消费者出列任务。下面例子中,AutoResetEvent用来通知工作线程,只有在用完任务事等待。集合类队列来表示,通过锁来控制访问确保线程安全,队列为Null时结束任务。

     class ProducerConsumerQueue : IDisposable
    {
        EventWaitHandle wh = new AutoResetEvent(false);
        Thread worker;
        object locker = new object();
        Queue<string> tasks = new Queue<string>();

        public ProducerConsumerQueue()
        {
            worker = new Thread(Work);
            worker.Start();
        }

        public void EnqueueTask(string task)
        {
            lock (locker) tasks.Enqueue(task);
            wh.Set();
        }

        public void Dispose()
        {
            EnqueueTask(null);     // Signal the consumer to exit.
            worker.Join();          // Wait for the consumer's thread to finish.
            wh.Close();             // Release any OS resources.
        }

        void Work()
        {
            while (true)
            {
                string task = null;
                lock (locker)
                    if (tasks.Count > 0)
                    {
                        task = tasks.Dequeue();
                        if (task == null) return;
                    }
                if (task != null)
                {
                    Console.WriteLine("Performing task: " + task);
                    Thread.Sleep(1000);
                }
                else
                    wh.WaitOne();
            }
        }
    }

        static void Main(string[] args)
        {
            using (ProducerConsumerQueue q = new ProducerConsumerQueue())
            {
                q.EnqueueTask("Hello");
                for (int i = 0; i < 10; i++) q.EnqueueTask("Say " + i);
                q.EnqueueTask("Goodbye!");
            }
            Console.ReadKey();
        }

C#多线程(三)_第4张图片

5、ManualResetEvent

ManualResetEvent是AutoResetEvent变化的一种形式,它的不同之处在于:在线程被WaitOne的调用而通过的时候,它不会自动地Reset,这个过程就像大门一样——调用Set打开门,允许任何数量的已执行WaitOne的线程通过;调用Reset则关闭大门。此时会阻塞所有调用了WaitOne的线程,只到下次调用Set时,可能会引起一系列的“等待者”在打开门时全部释放。除了这个区别之外,其他都和AutoResetEvent的用法一样。他们的速度都在一个微妙级别。

ManualResetEvent有时被用于给一个完成的操作发送信号,又或者一个已初始化正准备执行工作的线程。

在NET4.0中出现了优化的ManualResetEventSlim类,这个可以在等待的时候有更快的响应时间,可以通过CancellationToken来取消Wait,同时这个类不是WaitHandle类的子类,而是组合了WaitHandle类的一个对象作为一个属性,返回一个WaitHandle基类对象。同时优化过的这个类的速度是原来的50倍,也就是20个纳秒级别。

ManualResetEvent是让一个线程解除一系列等待的线程的阻塞

6、CountdownEvent

CountdownEvent是NET4.0中新增的类,对于使用之前版本的NET的时候,可以使使用Wait和Paulse自己写一个同样功能的类。这个类是用来等待一系列线程执行完毕之后通知当前等待的这个线程执行

CountdownEvent countdown= new CountdownEvent(3);  //初始化等待线程数为3个

通过调用Signal方法来减小初始化的数目count,通过调用Wait来阻塞,只到count数为0的时候就接触阻塞。

static CountdownEvent _countdown = new CountdownEvent (3);
 
static void Main()
{
  new Thread (SaySomething).Start ("I am thread 1");
  new Thread (SaySomething).Start ("I am thread 2");
  new Thread (SaySomething).Start ("I am thread 3");
 
  _countdown.Wait();   // Blocks until Signal has been called 3 times
  Console.WriteLine ("All threads have finished speaking!");
}
 
static void SaySomething (object thing)
{
  Thread.Sleep (1000);
  Console.WriteLine (thing);
  _countdown.Signal();
}

三个线程启动之后,先执行Sleep,在输出过程出现了CPU轮换时间片的随机性,每次的结果都是不同的。主线程等待三工作线程执行完毕之后再执行就是此处线程同步通信所演示的效果。

当count还没有达到0的时候,可以使用AddCount增加,可以使用Reset重置为初始化的count数目,但是如果达到了0之后,再调用AddCount则会引发异常,此时可以使用TryAddCount,当count为0的时候返回false。


参考:http://www.albahari.com/threading/part2.aspx


你可能感兴趣的:(线程安全,并行编程)