C#多线程、并行和异步编程学习笔记

一,进程、应用程序域和对象上下文

1,CLR

CLR(Common Language Runtime,公共语言运行库),主要作用使定位、加载和管理.Net类型,同时负责一些底层细节的工作,如内存管理、应用托管、处理线程、安全检查等。

2,进程

进程是一个运行程序。进程是一个操作系统级别的概念,用来描述一组资源(比如外部代码库和主线程)和程序运行必须的内存分配。对于每一个加载到内存的*.exe,在它的生命周期中操作系统会为之创建一个单独且隔离的进程。 
由于一个进程的失败不会影响其他进程,使用这种隔离方式,运行环境将更加健壮和稳定。此外,一个进程无法访问另外一个进程中的数据,除非 使用WCF这种分布式计算编程的API。所以,进程是一个正在运行的应用程序的固定的安全的边界。 
每一个Windows进程都有一个唯一的进程标识符,即PID,当需要时,它们能被操作系统加载和卸载。

3,线程

线程是进程中的基本执行单元。 
每一个Windows进程都包含一个用作程序入口点的主线程。进程的入口点创建的第一个线程被称为主线程。.Net控制台程序使用Main()方法作为程序入口点。当调用该方法时,会自动创建主线程。 
仅包含一个主线程的进程是线程安全的,这是由于在某个特定时刻只有一个线程访问程序中的数据。然而,如果这个线程正在执行一个复杂的操作,那么这个线程所在的进程(特别是GUI程序)对用户来说会显得没有响应一样。 
因此,主线程可以产生次线程(也称为工作者线程,worker thread)。每一个线程(无论主线程还是次线程)都是进程中的一个独立执行单元,它们能够同时访问那些共享数据。 
开发者可以使用多线程改善程序的总体响应性,让人感觉大量的活动几乎在同一时间发送。比如,一个应用程序可以产生一个工作者线程来执行强度大的工作。当这个次线程正在忙碌的时候,主线程仍然对用户的输入保持响应,这使得整个进程具有更强的性能。当然,如果单个进程中的线程过多的话,性能反而会下降,因为CPU需要花费不少时间在这些活动的线程来回切换。 
单CPU的计算机并没有能力在同一时间运行多个线程。准确地说,在一个单位时间(即一个时间片)内,单CPU只能根据线程优先级执行一个线程。当一个线程的时间片用完的时候,它会被挂起,以便执行其他线程。对于线程来说,它们需要在挂起前记住发生了什么,它们把这些情况写到线程本地存储中(Thread Local Storage,TLS),并且它们还要获得一个独立的调用栈(call stack)。 
这里写图片描述

4,.Net应用程序域

实际上,.Net可执行程序承载在进程的一个逻辑分区中,称为应用程序域(AppDomain)。一个进程可以包含多个应用程序域,每一个应用程序域中承载一个.Net可执行程序。 
单个进程可以承载多个应用程序域,其中每一个程序域都和该进程(或其它进程)中其它的程序域完全彻底隔离开。如果不使用分布式编程协议(如WCF),运行在某个应用程序域中的应用程序将无法访问其它应用程序域中的任何数据(无论是全局变量还是静态变量)。

5,对象上下文

应用程序域是承载.Net程序集的进程的逻辑分区。应用程序域也可以进一步被划分成多个上下文边界。即,.Net上下文为单独的应用程序域提供了一种方式,该方式能为一个给定对象建立“特定的家”。 
和一个进程定义了默认的应用程序域一样,每个应用程序域都有一个默认的上下文。这个默认的上下文(由于它总是应用程序创建的第一个上下文,所以有时称为上下文0,即context0)用于组合那些对上下文没有具体的或唯一性需求的.Net对象。大多数.Net对象都会被加载打上下文0中。如果CLR判断一个新创建的对象有特殊需求,一个新的上下文边界将会在承载它的应用程序域中被创建。 
举例来说,如果定义一个需要自动线程安全(使用[Synchronization]特性)的C#类型,CLR将会在分配期间创建“上下文同步”。如果一个已分配的对象从一个同步的上下文转移到一个非同步的上下文,对象将突然不再是线程安全的并且极有可能变成大块的坏数据,而大量线程还在视图与这个(现在已是线程不稳定的)引用对象交互。 
这里写图片描述

进程、应用程序域和上下文是熟悉多线程编程需要了解的知识点。

  • 一个.Net进程可以承载多个应用程序域。每一个应用程序域可以承载多个相关的.Net程序集,并且可由CLR独立地加载或卸载应用程序域。
  • 一个给定的应用程序域中包含一个或多个上下文。使用上下文,CLR能够将“有特殊需求的”对象放置到一个逻辑容器中,确保该对象的运行时需要能够被满足。
  • -

二,多线程的并发问题

几乎无法控制底层操作系统和CLR对线程的调度。 
举例来说,如果精心编写一段创建一个新线程的代码,你不能保证这个线程被立即执行。更准确地说,这段代码仅仅通知操作系统或CLR尽快地执行这个线程(通常是线程调度程序给这个线程分配时间)。

线程具有不稳定操作(thread-volatile)和原子型(atomic)操作。 
举例来说,假如有一个线程正则调用某个特定对象的一个方法,为了让另一个线程也访问同一对象的同一方法,线程调度程序将发出指令挂起第一个线程。 
而此时,如果前一个线程没有全部完成当前的操作,那么后来的线程可能看到对象处于被部分修改状态。这样它所读到的数据基本上是虚假的,而这会使应用程序发生非常奇怪的(并且是非常难以发现的)bug,而且这些bug都难以重现和调试。 
另一方面,原子型操作在多线程环境下总是(线程)安全的。可是.Net基础类库中只有很少的操作能保证原子型。甚至将一个赋值给一个成员变量的操作也不是原子型的。


三,创建次线程

Thred类支持设置Name属性。如果没有设置这个值的话,Name将返回一个空字符串。如果需要用vs调试的话,可以为线程设置一个友好的Name,方便debug。

Thred类定义了一个名为Priority的属性,默认情况下,所有线程的优先级都处于Normal级别。但是,在线程生命周期的任何时候,都可以使用ThredPriority属性修改线程的优先级。 
如果给线程的优先级指定一个非默认值,这并不能控制线程调度器切换线程的过程。实际上,一个线程的优先级仅仅是把线程活动的重要程度提供给CLR。因此,一个带有Highest优先级的线程并不一定保证能得到最高的优先级。 
理论上,提高一些线程的优先级别会阻止那些低优先级别的线程执行任务。

使用ThreadStart委托创建线程

 

class Program
    {
        static void Main(string[] args)
        {
            Printer p = new Printer();
            Thread backgroundThread = new Thread(new ThreadStart(p.PrintNumbers));
            backgroundThread.Start();

            Console.ReadLine();
        }
    }

    public class Printer
    {
        public void PrintNumbers()
        {
            Console.Write("Your numbers: ");
            for (int i = 0; i < 10; i++)
            {
                Console.Write("{0}, ", i);
                Thread.Sleep(2000);
            }
            Console.WriteLine();
        }
    }
  •  
  • 使用ParameterizedThreadStart委托创建线程,传递数据

ThreadStart委托仅仅支持指向无返回值、无参数的方法。如果想把数据传递给在次线程上执行的方法,则需要使用ParameterizedThreadStart委托类型。

 

class Program
    {
        static void Main(string[] args)
        {
            AddParams ap = new AddParams(10, 10);
            Thread t = new Thread(new ParameterizedThreadStart(Add));
            t.Start(ap);

            Console.ReadLine();
        }

        static void Add(object data)
        {
            if (data is AddParams)
            {
                AddParams ap = (AddParams)data;
                Console.WriteLine("{0} + {1} is {2}",
                  ap.a, ap.b, ap.a + ap.b);

            }
        }
    }

    class AddParams
    {
        public int a, b;

        public AddParams(int numb1, int numb2)
        {
            a = numb1;
            b = numb2;
        }
    }

使用AutoResetEvent 类强制线程等待,直到其他线程结束

class Program
    {
        private static AutoResetEvent waitHandle = new AutoResetEvent(false);
        static void Main(string[] args)
        {
            AddParams ap = new AddParams(10, 10);
            Thread t = new Thread(new ParameterizedThreadStart(Add));
            t.Start(ap);

            // Wait here until you are notified         
            waitHandle.WaitOne();

            Console.WriteLine("Other thread is done!");

            Console.ReadLine();
        }

        static void Add(object data)
        {
            if (data is AddParams)
            {
                AddParams ap = (AddParams)data;
                Console.WriteLine("{0} + {1} is {2}",
                  ap.a, ap.b, ap.a + ap.b);

                // Tell other thread we are done.
                waitHandle.Set();
            }
        }
    }

    class AddParams
    {
        public int a, b;

        public AddParams(int numb1, int numb2)
        {
            a = numb1;
            b = numb2;
        }
    }

前台线程和后台线程

前台线程能阻止应用程序的终结。一直到所有的前台线程终止后,CLR才能关闭应用程序(即卸载承载的应用程序域)。 
后台线程被CLR认为是程序执行中可做出牺牲的线程,即在任何时候(即使这个线程此时正在执行某项工作)都可能被忽略。因此,如果所有的前台线程终止,当应用程序卸载时,所有的后台线程也会被自动终止。

前台线程和后台线程并不等同于主线程和工作者线程。默认情况下,所有通过Thread.Start()方法创建的线程都自动成为前台线程。可以通过修改线程的IsBackground属性将前台线程配置为后台线程。

多数情况下,当程序的主任务完成,而工作者线程正在执行无关紧要的任务时,把工作线程配置成后台类型时很有用的。例如,构建一个每隔几分钟就ping一次邮件服务器看有没有新邮件的应用程序,或更新当前天气条件等其他无关紧要的任务。


四,解决线程的并发问题

在构建多线程应用程序时,需要确保任何共享数据都需要处于被保护状态,以防止多个线程修改它的值。由于一个应用程序域中的所有线程都能够并发访问共享数据,所以,想象一下当它们正在访问其中的某个数据项时,由于线程调度器会随机挂起线程,所以如果线程A在完成之前被挂起了,线程B读到的就是一个不稳定的数据。

    public class Printer
    {

        public void PrintNumbers()
        {
            // Display Thread info.
            Console.WriteLine("-> {0} is executing PrintNumbers()",
              Thread.CurrentThread.Name);

            // Print out numbers.
            Console.Write("Your numbers: ");
            for (int i = 0; i < 10; i++)
            {
                Random r = new Random();
                Thread.Sleep(100 * r.Next(5));
                Console.Write("{0}, ", i);
            }
            Console.WriteLine();
        }
    }


    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("*****Synchronizing Threads *****\n");

            Printer p = new Printer();

            // Make 10 threads that are all pointing to the same
            // method on the same object.
            Thread[] threads = new Thread[10];
            for (int i = 0; i < 10; i++)
            {
                threads[i] =
                  new Thread(new ThreadStart(p.PrintNumbers));
                threads[i].Name = string.Format("Worker thread #{0}", i);
            }

            // Now start each one.
            foreach (Thread t in threads)
                t.Start();
            Console.ReadLine();
        }
    }

每一次执行都可能产生不同的输出结果。当每个线程都调用printer来输出数字的时候,线程调度器可能正在切线程。这导致了不同的输出结果。此时需要通过编程控制对共享数据的同步访问。

使用C#的lock关键字进行同步

同步访问共享资源的首先技术是C#的lock关键字。这个关键字允许定义一段线程同步的代码语句。采用这项技术,后进入的线程不会中断当前线程,而是停止自身下一步执行。lcok关键字需要定义一个标记(即一个对象引用),线程进入锁定范围的时候必须获得这个标记。当试图锁定的是一个实例级对象的私有方法时,使用方法本身所在对象的引用就可以,如下所示:

    //使用当前对象作为线程标记
        private void Do()
        {
            lock (this)
            {
                //所有在这个范围内的代码是线程安全的
            }
        }

然而,如果需要锁定公共成员中的一段代码,比较安全也比较推荐的方式是声明私有的object成员作为锁标识,如上面的printer方法如果需要线程同步,可以修改为:

    // Lock token.
        private object threadLock = new object();

        public void PrintNumbers()
        {
            lock (threadLock)
            {
                // Display Thread info.
                Console.WriteLine("-> {0} is executing PrintNumbers()",
                  Thread.CurrentThread.Name);

                // Print out numbers.
                Console.Write("Your numbers: ");
                for (int i = 0; i < 10; i++)
                {
                    Random r = new Random();
                    Thread.Sleep(100 * r.Next(5));
                    Console.Write("{0}, ", i);
                }
                Console.WriteLine();
            }
        }
    }
  • 3

此时在执行上面的printer代码,每一次执行的结果都会相同。 
一旦一个线程进入锁定范围,在它退出锁定范围且释放锁定之前,其他线程将无法访问锁定标记。如果线程A获得锁定标记,直到它放弃这个锁定标记,其他线程才能够进入锁定范围。

使用Interlocked类型进行同步

.Net中并不是所有赋值和数值运算都是原子型操作。Interlocked允许我们原子型操作单个数据。

成员 作用
CompareExchange 安全地比较两个值是否相等。如果相等,则替换其中一个值。
Decrement 以原子操作的形式递减指定变量的值并存储结果。
Exchange 以原子操作的形式,将对象设置为指定的值并返回对原始对象的引用。
Increment 以原子操作的形式递增指定变量的值并存储结果。

使用TimerCallback编程

许多程序需要定期调用具体的方法,可以使用TimerCallback编程。

    class Program
    {
        static void PrintTime(object state)
        {
            Console.WriteLine("Time is: {0}",
              DateTime.Now.ToLongTimeString());
        }

        static void Main(string[] args)
        {
            Console.WriteLine("***** Working with Timer type *****\n");

            // Create the delegate for the Timer type.
            TimerCallback timeCB = new TimerCallback(PrintTime);

            // Establish timer settings.
            Timer t = new Timer(
              timeCB,     // The TimerCallback delegate type.
              "Hello From Main",       // Any info to pass into the called method (null for no info).
              0,          // Amount of time to wait before starting.
              1000);      // Interval of time between calls (in milliseconds).

            Console.WriteLine("Hit key to terminate...");
            Console.ReadLine();
        }

    }

线程池

    public class Printer
    {
        private object lockToken = new object();

        public void PrintNumbers()
        {
            lock (lockToken)
            {
                // Display Thread info.
                Console.WriteLine("-> {0} is executing PrintNumbers()",
                  Thread.CurrentThread.ManagedThreadId);

                // Print out numbers.
                Console.Write("Your numbers: ");
                for (int i = 0; i < 10; i++)
                {
                    Console.Write("{0}, ", i);
                    Thread.Sleep(1000);
                }
                Console.WriteLine();
            }
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("***** Fun with the CLR Thread Pool *****\n");

            Console.WriteLine("Main thread started. ThreadID = {0}",
              Thread.CurrentThread.ManagedThreadId);

            Printer p = new Printer();

            WaitCallback workItem = new WaitCallback(PrintTheNumbers);

            // Queue the method 10 times
            for (int i = 0; i < 10; i++)
            {
                ThreadPool.QueueUserWorkItem(workItem, p);
            }

            Console.WriteLine("All tasks queued");
            Console.ReadLine();
        }

        static void PrintTheNumbers(object state)
        {
            Printer task = (Printer)state;
            task.PrintNumbers();
        }
    }

线程池的好处:

  • 线程池减少了线程创建、开始和停止的次数,提高了效率。
  • 使用线程池,能够使我们将注意力放到业务逻辑上而不是多线程架构上。

但是线程池中的线程总是后台线程,且它的优先级是默认的normal。


五,使用任务并行库进行并行编程

使用TPL并行编程库,可以构建细粒度的、可扩展的并行代码,而不必直接与线程和线程池打交道。

TPL(Task Parallel Library),即任务并行库,使用CLR线程池自动将应用程序的工作动态分配到可用的CPU中。TPL还处理工作分区、线程调度、状态管理和其他低级别的细节操作。使用TPL可以最大限度地提升.Net应用程序的性能,并且避免直接操作线程所带来的复杂性。

TPL通过Parallel类从线程池中为我们提取线程(和管理并发)。

数据并行

Parallel类支持两个主要的静态方法——Parallel.For和Parallel.ForEach方法,这两个方法以并行方式对数组或集合中的数据进行迭代

    class Program
    {
        // New Form level variable.
        private static CancellationTokenSource cancelToken = new CancellationTokenSource();

        static void Main(string[] args)
        {
            ProcessFiles();
            Console.WriteLine("ok");
            Console.ReadLine();
        }

        private static void ProcessFiles()
        {
            // Use ParallelOptions instance to store the CancellationToken
            ParallelOptions parOpts = new ParallelOptions();
            parOpts.CancellationToken = cancelToken.Token;
            parOpts.MaxDegreeOfParallelism = System.Environment.ProcessorCount;

            // Load up all *.jpg files, and make a new folder for the modified data.
            string[] files = Directory.GetFiles(@"C:\Users\Public\Pictures\Sample Pictures", "*.jpg",
                SearchOption.AllDirectories);
            string newDir = @"C:\ModifiedPictures";
            Directory.CreateDirectory(newDir);

            //  Process the image data in a parallel manner! 
            Parallel.ForEach(files, parOpts, currentFile =>
            {
                parOpts.CancellationToken.ThrowIfCancellationRequested();

                string filename = Path.GetFileName(currentFile);
                using (Bitmap bitmap = new Bitmap(currentFile))
                {
                    bitmap.RotateFlip(RotateFlipType.Rotate180FlipNone);
                    bitmap.Save(Path.Combine(newDir, filename));

                }
            }
            );
        }

    }
  •  
  •  

任务并行

TPL通过Parallel.Invoke方法触发多个异步任务。

    class Program
    {
        static void Main(string[] args)
        {
            Process();
            Console.WriteLine("ok");
            Console.ReadLine();
        }

        private static void Process()
        {
            string theEBook = "电子书的内容";
            // Get the words from the e-book.
            string[] words = theEBook.Split(new char[] { ' ', '\u000A', ',', '.', ';', ':', '-', '?', '/' },
                StringSplitOptions.RemoveEmptyEntries);
            string[] tenMostCommon = null;
            string longestWord = string.Empty;

            Parallel.Invoke(
                () =>
                {
                    // Now, find the ten most common words.
                    tenMostCommon = FindTenMostCommon(words);
                },
                () =>
                {
                    // Get the longest word. 
                    longestWord = FindLongestWord(words);
                });

            // Now that all tasks are complete, build a string to show all
            // stats in a message box.
            StringBuilder bookStats = new StringBuilder("Ten Most Common Words are:\n");
            foreach (string s in tenMostCommon)
            {
                bookStats.AppendLine(s);
            }
            bookStats.AppendFormat("Longest word is: {0}", longestWord);
            bookStats.AppendLine();
            Console.WriteLine(bookStats.ToString());
        }

        private static string[] FindTenMostCommon(string[] words)
        {
            var frequencyOrder = from word in words
                                 where word.Length > 6
                                 group word by word into g
                                 orderby g.Count() descending
                                 select g.Key;

            string[] commonWords = (frequencyOrder.Take(10)).ToArray();
            return commonWords;
        }

        private static string FindLongestWord(string[] words)
        {
            return (from w in words orderby w.Length descending select w).FirstOrDefault();
        }

    }
  •  
  •  
  •  

六,工作者线程和I/O线程

对于线程所执行的任务来说,可以将线程任务分为两种类型:工作者(worker)线程和I/0线程。

工作者线程用来完成计算密集的任务,在任务的执行过程中,需要CPU不间断地处理,所以,在工作者线程的执行过程中,CPU和线程的资源是充分利用的。

I/O线程典型的情况是用来完成输入和输出工作,在这种情况下,计算机需要通过I/O设备完成输入和输出任务。在处理过程中,CPU仅仅需要在任务开始的时候,将任务的参数传递给设备,然后启动硬件设备即可。等到任务完成的时候,CPU收到一个通知,一般来说,是一个硬件的中断信号,此时,CPU继续后续的处理工作。

在处理的过程中,CPU是不必完全参与处理过程的,如果正在运行的线程不交出CPU的控制权,那么,线程也只能处于等待状态,在任务完成后才会有事可做,此时,线程会处于等待状态。即使操作系统将当前的CPU调度给其他的线程,此时形成所占用的空间还将被使用,但是并没有CPU在使用这个线程,可能出现线程资源浪费的问题。

如果我们的程序是一个网络服务程序,针对一个网络连接都使用一个线程进行管理,那么,此时将会出现大量的线程在等待网络通信,随着网络连接的不断增加,处于等待状态的线程将会很快消耗尽所有的内存资源。

线程是一个昂贵的资源,仅仅从内存的角度来说,每个线程就将占用1M以上的内存,而且,初始化内存中的数据结构,包括在销毁线程时的处理,都更加显得线程是一个昂贵的资源。

针对这种情况,我们可以考虑使用少量的线程来管理大量的网络连接,比如说,在启动输入输出处理之后,只使用一个线程监控网络通信的状况,在这种情况下,需要进行网络通信的线程在启动通信开始之后,就已经可以结束了,也就是说,可以被系统回收了。在通信的传输阶段,由于不需要CPU参与,可以没有线程介入。监控线程将负责在信息到达之后,重新启动一个计算密集的线程完成本地的处理工作。这样带来的好处就是将没有线程处于等待状态消耗有限的内存资源。

所以,对于I/O线程来说,可以将输入输出的操作分为三个步骤:启动、实际输入输出、处理结果。由于实际的输入输出可由硬件完成,并不需要CPU的参与,而启动和处理结果也并不需要必须在同一个线程上进行。

为了提高线程的利用效率,减少创建线程、销毁线程所带来的效率损失,同时也为了能够节约宝贵的内存,可以考虑创建一个线程池,提供线程的工厂服务,这样,就没有必要总是创建新的线程,而是当需要线程的时候从线程池中借出一个线程,当不再使用这个线程的时候,将这个线程归还给线程池,以方便后继的使用。


七,异步编程

C#的async关键字用来指定某个方法,Lambda表达式或匿名方法自动以异步的方式来调用,CLR会创建新的执行线程来处理任务。在调用async方法时,await关键字会自动暂停当前线程中任何其他活动,知道任务完成,离开调用线程,并继续未完成的任务。

    class Helper
    {
        public async Task DoWorkAsync()
        {
            return await Task.Run(() =>
            {
                Thread.Sleep(10000);
                return "Done with work!";
            });
        }

        //返回void的异步方法
        public async Task MethodReturningVoidAsync()
        {
            await Task.Run(() =>
            { /* Do some work here... */
                Thread.Sleep(4000);
            });
        }

        //具有多个await的异步方法
        public async void MutliAwait()
        {
            await Task.Run(() => { Thread.Sleep(2000); });
            Console.WriteLine("Done with first task!");

            await Task.Run(() => { Thread.Sleep(2000); });
            Console.WriteLine("Done with second task!");

            await Task.Run(() => { Thread.Sleep(2000); });
            Console.WriteLine("Done with third task!");
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            Test1();
            Test2();
            Test3();
            Console.WriteLine("ok");
            Console.ReadLine();
        }

        private static async void Test1()
        {
            Helper helper = new Helper();
            string str = await helper.DoWorkAsync();
            Console.WriteLine(str);
        }

        private static async void Test2()
        {
            Helper helper = new Helper();
            await helper.MethodReturningVoidAsync();
        }

        private static void Test3()
        {
            Helper helper = new Helper();
            helper.MutliAwait();
        }

    }
  •  
  •  

方法标记了async关键字,表示该方法可以作为非阻塞式调用的成员。如果用async关键字修饰某个方法,但方法内部没有一个await方法调用,那么实际上仍将构建一个阻塞的、同步的方法调用,实际上回得到一个编译器警告。


八,参考资料

《精通C#(第6版)》 
《ASP.NET本质论》

你可能感兴趣的:(C#)