代码改变世界

C# 线程、线程池、Task概念+代码实践

2021-02-19 10:59  石吴玉  阅读(811)  评论(1编辑  收藏  举报

本文转发自公众号“DotNetCore实战”,地址:https://mp.weixin.qq.com/s/epTJXvuQjLVsdKJ3Y_qpsQ

前言

线程中的概念很多,如果没有代码示例来理解,会比较晦涩,而且有些概念落不到实处,因此,本文以一些运行示例代码,结果来阐述线程中的一些基础概念。让自己跟读者一起把线程中的概念理解地更深刻。

 

一、线程安全

 

1.1、未出现线程抢占

class ThreadTest2
{
    bool done;
    static void Main()
    {
        ThreadTest2 tt = new ThreadTest2();   // Create a common instance
        new Thread(tt.Go).Start();
        tt.Go();
    }
    // Note that Go is now an instance method
    void Go()
    {
            if (!done)
            {
                done = true;
                Console.WriteLine("Done");             
            }            
    }
}
View Code

运行结果如下:

 

1.2、线程抢占

class ThreadTest2
{
    bool done;
    static void Main()
    {
        ThreadTest2 tt = new ThreadTest2();   // Create a common instance
        new Thread(tt.Go).Start();
        tt.Go();
    }
    // Note that Go is now an instance method
    void Go()
    {
            if (!done)
            {                   
                Console.WriteLine("Done");        
                done = true;     
            }            
    }
}
View Code

运行结果如下:

 

 

线程抢占例子2:

for (int i = 0; i < 10; i++)
  new Thread (() => Console.Write (i)).Start();

运行结果

 

 

1.3、避免线程抢占

class ThreadTest2
{
    static readonly object locker = new object();
    bool done;
    static void Main()
    {
        ThreadTest2 tt = new ThreadTest2();   // Create a common instance
        new Thread(tt.Go).Start();
        tt.Go();
    }
    // Note that Go is now an instance method
    void Go()
    {
        lock (locker)
        {
            if (!done)
            {                  
              Console.WriteLine("Done");
              done = true;
            }
        }
    }
}
View Code

运行结果如下:

 

二、线程阻塞

class Program
{
    static void Main()
    {
        Thread t = new Thread(Go);
        t.Start();
        t.Join();
        Console.WriteLine("Thread t has ended!");
    }
    static void Go()
    {
        for (int i = 0; i < 1000; i++) Console.Write("y");
    }
}
View Code

 

运行结果:

 

1000个y打印完毕才输出"Thread t has ended!"。

也会阻塞线程,让渡CPU的执行权给其他线程。

 

三、Thread.yield()和Thread.sleep(0)

 

sleep(0)效果相当于yield(),会让当前线程放弃剩余时间片,进入相同优先级线程队列的队尾,只有排在前面的所有同优先级线程完成调度后,它才能再次获执行的机会。

 

四、线程如何工作

 

多线痛通过内部的线程调度器(thread scheduler)管理,通过clr委托操作系统。线程调度器会分配适当的执行时间给活动线程,线程等待(锁)或者线程阻塞(用户输入)不会消耗cpu执行时间。

 

单核处理器电脑上,在Windows,时间片通常会被分配几十毫秒,远大于线程上下文切换还时间几毫秒。

 

在多处理器计算机上,多线程是通过时间片和真正的并发实现的,其中不同的线程在不同的CPU上同时运行代码。几乎可以肯定,由于操作系统需要服务自己的线程以及其他应用程序的线程,因此还会有一定的时间片。

 

当线程的执行由于诸如时间片之类的外部因素而被中断时,该线程被认为是被抢占的。在大多数情况下,线程无法控制其被抢占的时间和地点。

 

五、线程与进程

 

线程与进程有相似之处。就像进程在计算机上并行运行一样,多个线程在单个进程中并行运行。进程彼此完全隔离;线程的隔离度有限。特别是,线程与在同一应用程序中运行的其他线程共享(堆)内存。这就是为什么线程有用的原因:例如,一个线程可以在后台获取数据,而另一个线程可以在数据到达时显示数据。

 

六、线程的使用和滥用

 

  • 利于响应式用户界面

 

在同时并行运行的"worker"线程上运行耗时的任务,主UI线程可以自由继续处理键盘和鼠标事件。

 

  • 有效利用原本被阻塞的CPU

 

当线程正在等待来自另一台计算机或硬件的响应时,多线程很有用。当一个线程在执行任务时被阻塞时,其他线程可以利用本来没有负担的计算机的其他线程来响应任务。

 

  • 并行编程

 

如果以"分而治之"策略在多个线程之间共享工作负载,则执行密集计算的代码可以在多核或多处理器计算机上更快地执行(请参阅第5部分)。

 

  • 随机执行

 

在多核计算机上,有时可以通过预测可能需要完成的事情然后提前进行来提高性能。LINQPad使用此技术来加速新查询的创建。一种变化是并行运行许多不同的算法,这些算法都可以解决同一任务。谁先获得“胜利”,当您不知道哪种算法执行速度最快时,此方法将非常有效。

 

  • 允许服务同时处理请求

 

在服务器上,客户端请求可以同时到达,因此需要并行处理(如果使用ASP.NET,WCF,Web服务或远程处理,.NET Framework会为此自动创建线程)。这在客户端上也很有用(例如,处理对等网络-甚至来自用户的多个请求)。

 

使用ASP.NET和WCF之类的技术,您如果不知道多线程正在发生-除非您在没有适当锁定的情况下访问共享数据(可能通过静态字段),会破坏线程安全性。

 

线程之间的交互(通常是通过共享数据),会带来很多复杂性,但却不可避免,因此,有必要将交互保持在最低限度,并尽可能地坚持简单可靠的设计。

 

好的策略是将多线程逻辑封装到可重用的类中,这些类可以独立检查和测试。框架本身提供了许多更高级别的线程结构,我们将在后面介绍。

 

线程化还会在调度和切换线程时(如果活动线程多于CPU内核)会导致资源和CPU的浪费,并且还会产生创建/释放成本。多线程并不总是可以加快您的应用程序的速度-如果使用过多或使用不当,它甚至可能减慢其速度。例如,当涉及大量磁盘I / O时,让几个工作线程按顺序运行任务比一次执行10个线程快得多。

 

七、线程传参

 

7.1、lambda表达式传参

 

最方便的方法就是通过lambda表达式调用匿名方法,传参数。

static void Main()
{
    Thread t = new Thread(() =>Print("Hello from t!"));
    t.Start();
}
static void Print(string message)
{
    Console.WriteLine(message);
}
View Code

 

7.2、线程start方法传参

static void Main()
{
    Thread t = new Thread(Print);
    t.Start("Hello from t!");
}
static void Print(object messageObj)
{
    string message = (string)messageObj;   
    // We need to cast here
    Console.WriteLine(message);
}
View Code

 

7.3、线程创建需要时间

string text = "t1";
Thread t1 = new Thread ( () => Console.WriteLine (text) );

text = "t2";
Thread t2 = new Thread ( () => Console.WriteLine (text) );

t1.Start();
t2.Start();
View Code

运行结果:

 

 

以上运行结果说明,在t1线程创建之前text被修改成了t2。

 

八、线程命名

 

每个线程都有名称属性,目的是为了更方便调试。

static void Main()
{
    Thread.CurrentThread.Name = "main";
    Thread worker = new Thread(Go);
    worker.Name = "worker";
    worker.Start();
    Go();
}
static void Go()
{
    Console.WriteLine("Hello from " + Thread.CurrentThread.Name);
}
View Code

 

运行结果:

 

 

九、前台线程与后台线程

Thread worker = new Thread(() => Console.ReadLine());
if (args.Length > 0) worker.IsBackground = true;
worker.Name = "backThread";
worker.Start();
Console.WriteLine("finish!");
View Code

 

前台线程会随着主线程窗口关闭而停止,后台线程及时主线程窗口关闭自己独立运行。

 

十、线程优先级

 

线程优先级决定了操作系统执行活动线程时间的长短。

 

 

 

有时候提高了线程的优先级,但却仍然无法满足一些实时的应用需求,这时候就需要提高进程的优先级,System.Diagnostics命名空间中的process进程类.

 

 

实际上,ProcessPriorityClass.High比最高优先级低1个级别:Realtime。将进程优先级设置为Realtime,可指示OS,您永远不希望该进程将CPU时间浪费在另一个进程上。如果您的程序进入意外的无限循环,您甚至可能会发现操作系统已锁定,只剩下电源按钮可以拯救您!因此,高通常是实时应用程序的最佳选择。

 

如果您的实时应用程序具有用户界面,则提高处理优先级将给屏幕更新带来过多的CPU时间,从而减慢整个计算机的速度(尤其是在UI复杂的情况下)。降低主线程的优先级并提高进程的优先级可确保实时线程不会因屏幕重绘而被抢占,但不会解决使其他应用程序耗尽CPU时间的问题,因为操作系统仍会分配 整个过程的资源不成比例。理想的解决方案是使实时工作程序和用户界面作为具有不同进程优先级的单独应用程序运行,并通过远程处理或内存映射文件进行通信。内存映射文件非常适合此任务。我们将在C#4.0的第14和25章中简要介绍它们的工作原理。

 

十一、异常处理

 

Go无法补捉异常,GoCatch能捕获当前线程的异常,输出Console.WriteLine("exception.");由此可见,线程创建之后,异常只能由本线程捕获,如果其调用方需要捕获,则得用共享内存方式往上传,Task帮我们做了这件事,调用方可在task.result里捕获到其他线程的异常。

 

public static void Main()
{
    try
    {
        new Thread(Go).Start();
        Console.ReadKey();
    }
    catch (Exception ex)
    {
        // We'll never get here!
        Console.WriteLine("Exception!");
    }
}
static void Go() { throw null; }   // Throws a NullReferenceException
static void GoCatch()
{
    try
    {
        // ...
        throw null;    // The NullReferenceException will get caught below
                      // ...
    }
    catch (Exception ex)
    {
        // Typically log the exception, and/or signal another thread
        // that we've come unstuck
        // ...
        Console.WriteLine("exception.");
    }
}
View Code

 

十二、线程池

 

当你创建一个线程,几百毫秒会被花费在例如创建本地私有变量堆栈。每个线程都会默认消耗1MB内存,从而允许在非常精细的级别上应用多线程而不会影响性能。当利用多核处理器以“分而治之”的方式并行执行计算密集型代码时,这很有用。

 

线程池还限制了将同时运行的工作线程总数。活动线程过多会限制操作系统的管理负担,并使CPU缓存无效。一旦达到限制,作业将排队并仅在另一个作业完成时才开始。这使任意并发的应用程序成为可能,例如Web服务器。(异步方法模式是一种先进的技术,通过高效利用池线程来进一步实现这一点;我们在C#4.0的第23章中简要介绍了这一点)。

 

有多种进入线程池的方法:

 

  • 通过Task Parallel Library(来自Framework 4.0)

     

  • 通过调用ThreadPool.QueueUserWorkItem

     

  • 通过异步委托(await)

     

  • 通过BackgroundWorker

 

以下方法间接使用线程池:

 

  • WCF,远程,ASP.NET和ASMX Web服务应用程序服务器

     

  • System.Timers.Timer和System.Threading.Timer

     

  • 以Async结尾的框架方法,例如WebClient(基于事件的异步模式)上的框架方法和大多数BeginXXX方法(异步编程模型模式)

     

  • PLINQ

 

使用池线程时,需要注意以下几点:

 

  • 无法设置池线程的名称,这会使调试更加困难(尽管您可以在Visual Studio的“线程”窗口中进行调试时附加说明)。

     

  • 池线程始终是后台线程(这通常不是问题)。

     

  • 除非您调用ThreadPool.SetMinThreads(请参阅优化线程池),否则阻塞线程池可能会在应用程序的早期阶段触发额外的延迟。

 

可以自由更改池线程的优先级-将其释放回池后将恢复为正常状态。

 

可以通过Thread.CurrentThread.IsThreadPoolThread属性查询当前是否在线程池上执行。

 

12.1、通过TPL进入线程池

 

通过Task Parallel Library库中的Task类可轻松使用线程池,Task类由Framework 4.0引入,如果你熟悉老的结构,考虑用不带泛型Task类来替代ThreadPool.QueueUserWorkItem,而泛型Task 代表的是一个异步委托。新的结构更快,更方便,比老的更灵活。

 

使用不带泛型例子的Task类,调用Task.Factory.StartNew,传递一个目标方法的委托;

static void Main()    // The Task class is in System.Threading.Tasks
{
    var task=Task.Factory.StartNew(Go);
    Console.WriteLine("main");
    task.Wait() ;
    Console.WriteLine(task.Result);
    Console.ReadLine();
}
static string Go()
{
    if (Thread.CurrentThread.IsThreadPoolThread)
    { Console.WriteLine("Hello from the thread pool!"); }
    else { Console.WriteLine("Hello just from the thread!"); }
    return "task complete!";
}
View Code

输出结果:

 

 

12.1.1、Task异常捕获

 

static void Main()    // The Task class is in System.Threading.Tasks
{
    var task=Task.Factory.StartNew(Go);
    Console.WriteLine("main");
    try
    { task.Wait(); }                                   
     catch (Exception e)
    {
        Console.WriteLine("exception!");
    }
    Console.WriteLine(task.Result);
    Console.ReadLine();
}
static string Go()
{
    if (Thread.CurrentThread.IsThreadPoolThread)
    { Console.WriteLine("Hello from the thread pool!"); }
    else { Console.WriteLine("Hello just from the thread!"); }
    throw null;
    return "task complete!";
}
View Code

 

运行结果,在主线程中捕获到了其他线程的异常:

 

 

static void Main()
{
  // Start the task executing:
  Task<string> task = Task.Factory.StartNew<string>
    ( () => DownloadString ("http://www.linqpad.net") );
  // We can do other work here and it will execute in parallel:
  RunSomeOtherMethod();
  // When we need the task's return value, we query its Result property:
  // If it's still executing, the current thread will now block (wait)
  // until the task finishes:
  string result = task.Result;
}
static string DownloadString (string uri)
{
  using (var wc = new System.Net.WebClient())
    return wc.DownloadString (uri);
}
View Code

Task<string> 就是一个返回值为string的异步委托。

 

12.2、不同过TPL进入线程池

 

如果你的框架是.Net 4.0之前的,你可以不通过Task Parallel Library 进入线程池。

 

12.2.1、QueueUserWorkItem

static void Main()
{
    ThreadPool.QueueUserWorkItem(Go);
    ThreadPool.QueueUserWorkItem(Go, 123);
    Console.ReadLine();
}
static void Go(object data)   // data will be null with the first call.
{
    Console.WriteLine("Hello from the thread pool! " + data);
}
View Code

 

运行结果:

 

 

与Task不同:

 

  • 后续执行中无法返回执行结果;

     

  • 无法返回异常给调用者;

 

12.2.2 异步委托

 

委托的EndInvoke 做了3件事:

 

  • 阻塞等待;

     

  • 返回结果;

     

  • 向调用者跑出异常;

 

12.3、线程池优化

 

线程池从其池中的一个线程开始。分配任务后,池管理器会“注入”新线程以应对额外的并发工作负载(最大限制)。在足够长时间的不活动之后,如果池管理器怀疑这样做会导致更好的吞吐量,则可以“退出”线程。

 

可以通过调用ThreadPool.SetMaxThreads;来设置池将创建的线程的上限; 默认值为:

 

  • 32位环境中的Framework 4.0中的1023

     

  • 64位环境中的Framework 4.0中的32768

     

  • Framework 3.5中的每个内核250个

     

  • Framework 2.0中每个内核25个

 

还可以通过调用ThreadPool.SetMinThreads设置下限。下限的作用是微妙的:这是一种高级优化技术,它指示池管理器在达到下限之前不要延迟线程的分配。当存在阻塞的线程时,提高最小线程数可提高并发性。

 

默认的下限是每个处理器内核一个线程-允许全部CPU利用率的最小值。但是,在服务器环境(例如IIS下的ASP .NET)上,下限通常要高得多-多达50个或更多。

设置线程池最小线程数量。

 

本文代码Git:https://github.com/JerryMouseLi/Thread.git