C#异步编程学习笔记1 之 线程Thread

C#异步编程

线程 Thread

什么是线程

  • 线程是一个可执行路径,它可以独立于其它线程执行
  • 每个线程都在操作系统的进程内执行,而操作系统进程提供了程序运行的环境。
  • 单线程应用,在进程的独立环境中只跑一个线程,该线程拥有独占权
  • 多线程应用,单个进程中会跑多个线程,它们会共享当前执行环境(尤其是内存)。

线程被抢占:当前线程与另外一个线程上的代码的执行交织的那一点。

线程的部分属性

  • IsAlive:线程一旦开始执行,IsAlive为true,线程结束为false;
  • 线程结束的条件:线程构造函数传入的委托结束了运行。
  • 线程一旦结束,就无法再启动
  • Name属性,通常用于调试;线程的Name只能设置一次,以后更改会抛出异常。
  • 静态的Thread.CurrentThread属性,返回当前执行的线程。

Thread.Join() && Thread.Sleep()

  • 在当前线程中调用另一个线程的join()方法,则当前线程会暂停,等待另一个线程结束后,再进行当前线程。
  • Thread.Sleep()会暂停当前线程,并等待一段时间。
using System;
using System.Threading;

namespace CSharp_Thread_Learning_Demo
{
    class Program
    {
        static Thread thread1, thread2;
        public static void Main()
        {
            thread1 = new Thread(ThreadProc);
            thread1.Name = "Thread1";
            thread1.Start();

            thread2 = new Thread(ThreadProc);
            thread2.Name = "Thread2";
            thread2.Start();
        }

        private static void ThreadProc()
        {
            Console.WriteLine("\nCurrent thread:{0}", Thread.CurrentThread.Name);
            if (Thread.CurrentThread.Name == "Thread1" && thread2.ThreadState != ThreadState.Unstarted)
            {
                thread2.Join();
            }

            Thread.Sleep(2000);
            Console.WriteLine("\nCurrent thread:{0}", Thread.CurrentThread.Name);
            Console.WriteLine("Thread1:{0}", thread1.ThreadState);
            Console.WriteLine("Thread2:{0}", thread2.ThreadState);
        }
    }
}

【注意】

  • Thread.Sleep(0)会导致线程立即放弃本身当前的时间片,自动将CPU移交给其它线程
  • Thread.Yield()也可以暂停线程,但它只会把执行交给同一处理器上的其它线程
  • 当等待 Sleep或者 Join 时,线程处于阻塞状态

添加超时

调用Join()方法时,可以设置一个超时,用毫秒或者TimeSpan都可以并设置时间限制。如果返回True,线程结束;如此超时了,就返回false;

using System;
using System.Threading;

namespace CSharp_Thread_Learning_Demo
{
    class Program
    {
        static TimeSpan waitTime = new TimeSpan(0, 0, 1);  //设置超时,时长为1s

        public static void Main()
        {
            //添加超时
            Thread newThread = new Thread(ThreadRun);
            newThread.Start();
            if (newThread.Join(waitTime + waitTime))  //如果线程newThread在 2s 内执行完,则进入下面代码
            {
                Console.WriteLine("Terminated");
            }
            else
            {
                Console.WriteLine("Time out");
            }
        }

        private static void ThreadRun()
        {
            //此处省略具体线程实体
        }
    }
}

阻塞与解除阻塞

阻塞 Blocking
  • 如果线程在执行过程中,因为某种原因暂停,那边就认为该线程阻塞。如 Sleep 和 Join方法。
  • 被阻塞的线程会立即将处理器的时间片生成给其它线程,不再消耗处理器时间;直到满足其阻塞条件,线程继续执行。
  • 可以ThreadState这个属性可以判断线程是否处于被阻塞状态:
ThreadState属性
  • ThreadState是一个flags enum(枚举值),但它的大部分枚举值没什么用,其中四个最有价值的是
    • Unstarted
    • Running
    • WaitSleepJoin
    • Stopped
  • ThreadState属性可以用于诊断的目的,但是不适用于同步,因为线程状态可能会在测试ThreadState和对该信息进行操作之间会产生变化。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EQS48Tpa-1604877912234)(C:\Users\86183\Desktop\学习笔记\图片\image-20201101201031246.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TalpyxSq-1604877912244)(C:\Users\86183\Desktop\学习笔记\图片\image-20201101201354757.png)]

解除阻塞 Unblocking

当遇到以下四种情况下,接触阻塞:

  • 阻塞条件被满足
  • 操作超时(如果设置超时的话)
  • 通过 Thread.Interrupt() 进行打断
  • 通过 Thread.Abort() 进行中止
一些其它概念

上下文切换

​ 当线程阻塞或者接触阻塞时,操作系统将执行上下文切换。这会产生少量开销,一般时1或2微秒。

I/O-bound

​ 一个花费大部分时间等待某事发生的操作称为“I/O-bound”。I/O绑定操作通常涉及输入输出,但这不是固定的,Thread.Sleep()也被视为“I/O-bound”。

Compute-bound(CPU-bound)

​ 一个花费大部分时间执行 CPU密集型工作的称为“Compute-bound”。

忙等待(自旋 Spinning)

I/O-bound操作的工作方式有两种:

  • 在当前线程上的同步等待:Console.WriteLine(),Thread.Sleep(),Thread.Join()…
  • 异步的操作,在稍后操作完成时触发一个回调动作。

同步等待的I/O-bound操作将大部分的时间发在阻塞线程上。

忙等待(自旋):I/O-bound操作周期性的在一个循环里面“打转(自旋)”

//1、
while(DataTime.Now < nextStartTime);

//2、
while(DataTime.Now < nextStartTime)
    Thread.Sleep(100);

忙等待和阻塞存在一些细微的差别:

  • 如果希望条件很快地得到满足,则短暂自旋可能会很有效。因为自旋避免了上下文切换的开销和延迟。
    • .Net Framework 提供了特殊的方法和类来提供帮助(SpinLock和SpinWait)
  • 阻塞不是零成本。因为每个线程在生成期间会占用月1M的内存,并会给CLR和操作系统带来持续的管理开销。
    • 因此,在需要处理成百上千个并发操作的大量I/O-bound程序的上下文中,阻塞可能会很麻烦。
    • 故,此类程序需要使用基于回调的方法,在等待时完全撤销其线程。

本地 VS 共享的状态(Local VS Shared State)

Local 本地独立

CLR为每个线程分配自己的内存栈(Stack),以便使本地变量保存独立。

class Program
{
    static void Main()
    {
        new Thread(Go).Start();  //在新线程上调用Go()
        Go();  //在Main线程上调用Go
    }
    
    static void Go() 
    {
        //C 是本地变量。在每个线程的内存栈上,都会创建c独立的副本。
        for(int c = 0; c < 5; c++)
        {
            Console.Write("?");
        }
    }
}

Shared 共享

  • 如果多个线程都引用到同一个对象的实例,那么它们就共享了数据。

    class ThreadTest
    {
        bool _done;
        static void Main()
        {
            ThreadTest tt = new ThreadTest();  //创建了一个共同的实例
            new Thread(tt.Go).Start();
            tt.Go();
        }
        
        void Go() 
        {
            //由于两个线程在同一个 ThreadTest 实例上调用的 Go(),所以他们共享_done
            //结果就只打印一次 Done
            if(!_done)
            {
                _done = true;
                Console.WriteLine("Done");
            }
        }
    }
    

    结果:

    ​ 输出一个Done

  • 被Lambda表达式或匿名委托所捕获的本地变量,会被编译器转化为字段(field),所以也会被共享。

    class ThreadTest
    {
        static void Main()
        {
            bool done = false;
            
            ThreadStart action = () =>
            {
                if(!done)
                {
                    done = true;
                    Console.WriteLine("Done");
                }
            }
            
            new Thread(action).Start();
            action();
        }
    }
    

    结果:

    ​ 输出一个Done

  • 静态字段(field)也会在线程间共享数据。

    class ThreadTest
    {
        static bool _done;  //静态字段在同一个应用于下的所有线程中被共享
        
        static void Main()
        {
            new Thread(Go).Start();
            Go();
        }
        
        static void Go()
        {
            if(!_done)
            {
                _done = true;
                Console.WriteLine("Done");
            }
        }
    }
    

    结果:

    ​ 输出一个Done

线程安全 Thread Safety

上述“共享”中的三个例子都是线程不安全的。因为这些例子中,输出结果实际是不确定的。因为理论上“Done”可能被打印两次,尤其是当交换了Go方法中的语句顺序之后。

故,尽可能地避免使用共享状态

class ThreadTest
{
    static bool _done;  //静态字段在同一个应用于下的所有线程中被共享
    
    static void Main()
    {
        new Thread(Go).Start();
        Go();
    }
    
    static void Go()
    {
        if(!_done)
        {
            Console.WriteLine("Done");
            
            Thread.Sleep(100);
            
            _done = true;
        }
    }
}

结果:

​ 输出两个Done

锁定与线程安全(简介)

上述线程安全问题,可以通过“锁定 Locking”来解决。

  • 在读取和写入共享数据的时候,通过使用一个互斥锁(exclusive lock),就可以修复前面例子的问题。

  • C#中使用 lock 语句来枷锁

  • 当两个线程同时竞争一个锁的时候(锁可以基于任何引用类型对象),一个线程会等待或阻塞,直到锁变成可用状态。

    class ThreadSafe
    {
        static bool _done;
        static readonly object _locker = new object();
        
        static void Main()
        {
            new Thread(Go).Start();
            Go();
        }
        
        static void Go()
        {
            lock (_locker)
            {
                if(!_done)
                {
                    Console.WriteLine("Done");
                    _done = true;
                }
            }
        }
    }
    
  • 在多线程上下文中,以这种方式避免不确定性的代码就叫做线程安全。

向线程传递数据

  • 往线程的启动方法里传递参数,最简单的方式就是使用lambda表达式,在里面使用参数调用方法。

    class Program
    {
        static void Main()
        {
            Thread t = new Thread(() => Print("Hello"));
            t.Start();
        }
        
        static void Print(string message)
        {
            Console.WriteLine(message);
        }
    }
    
    class Program
    {
        static void Main()
        {
            new Thread(() =>
            {
                Console.WriteLine("Hello");
            }).Start();
        }
    }
    
  • 在 C#3.0之前,没有lambda表达式,可以使用Thread的Start方法来传递参数。

    class Program
    {
        static void Main()
        {
            Thread t = new Thread(Print);
            t.Start("HELLO");
        }
        
        static void Print(object messageObj)
        {
            string message = (string)messageObje;
            Console.WriteLine(message);
        }
    }
    
    • Thread的重载构造函数可以接收下列两个委托之一作为参数

      public delegate void ThreadStart();
      public delegate void ParameterizedThreadStart(object obj);
      
Lambda表达式与被捕获的变量

使用Lambda表达式可以很简单的给Thread传递参数。但是线程开始后,可能会不小心修改了被捕获的变量。

class Program
{
    static void Main()
    {
        //i在循环的整个声明周期内指向的是同一个内存地址
        //每个线程对Console.Write的调用都会在运行期间对它进行修改
        for(int i = 0; i < 10; i++)
        {
            new Thread(() => Console.Write(i)).Start();
        }
    }
}

结果:

​ 输出一堆无规律的数字,数字可能重复

解决方案:

class Program
{
    static void Main()
    {
        for(int i = 0; i < 10; i++)
        {
            int temp = i;
            new Thread(() => Console.Write(temp)).Start();
            //此处 顺序仍然无法保证,但是临时变量解决了上文的问题。
        }
    }
}

结果:

​ 输出数字0~9,数字不会重复,但是顺序仍然是是乱的。

异常处理

  • 创建线程时在作用范围内的 try/catch/finally 块,在线程开始执行后就与线程无关了。

    也就是说,如果在线程里面产生异常了,在new Thread外的 try/catch/finally 块 无法捕获线程里面异常。

    class Program
    {
        static void Main()
        {
            try
            {
                new Thread(Go).Start();
            }
            catch(Exception ex)
            {
                Console.WriteLine("Exception");  //此处时无法捕获异常的
            }
        }
        
        static void Go()
        {
            throw null;  //Throws a NullReferenceException
        }
    }
    

    补救方案:在Go()方法中捕获异常

    class Program
    {
        static void Main()
        {
            new Thread(Go).Start();
        }
        
        static void Go()
        {
            try
            {
                throw null;
            }
            catch(Exception ex)
            {
                Console.WriteLine("Exception");
            }
        }
    }
    
  • 在WPF、WinForm中,可以订阅全局异常处理事件:

    • Application.DispatcherUnhandledException
    • Application.ThreadException
    • 在通过消息循环调用的程序的任何部分发生未处理的异常后,将触发这些异常。(相当于主线程上有未处理的异常,则会触发以上两个事件。)
    • 但是非UI线程上的未处理异常,并捕获触发它。
  • 而在任何线程有任何未处理的异常时,都会触发AppDomain.CurrentDomain.UnHandledException

前台和后台线程(Foreground vs Background Threads)

默认情况下,手动创建的线程就是前台线程。

只要有前台线程在运行,应用程序就一直处于活动状态。

  • 只有后台进程时,应用程序就不会处于活动状态
  • 一旦所有前台线程停止,应用程序也就停止了,任何的后台线程也就会跟着停止了。

**【注意】**线程的前台、后台状态与它的优先级无关;优先级即所分配的执行时间。

  • 可以通过IsBacskground属性判断线程是否为后台线程

    class Program
    {
        static void Main(string[] args)
        {
            Thread worker  = new Thread(() => Console.WriteLine());
            if(args.Length > 0)
            {
                worker.IsBackground = true;	
            }
            worker.Start();
            
            //程序运行时,因为是手动创建的线程,是前台线程
            //若运行时不传入参数,此时程序会停止在控制台等待输入
            //若运行时传入参数,通过IsBacskground将线程设置为了后台线程。在执行完worker.Start()后,主线程执行完毕,此时没有前台线程了,程序终止。
        }
    }
    

    进程用以上这种形式终止的时候,后台线程执行栈中的finally块就不会执行了。(少数几种finally不会执行的情况之一)

    • 如果想让它执行,可以在退出程序时使用Join来等待后台线程(前提时自己创建的线程);
    • …还有其它一些方法,后续学到补充。

    **【注意】**应用程序无法正常退出的一个常见原因是还有活跃的前台线程。

线程优先级

线程的优先级(Thread的Priority属性)决定了相对于操作系统中其它活跃线程所占的执行时间。

优先级分为:

  • enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }
提升线程优先级

提升线程的优先级时需要特别注意,因为可能会“饿死”其它线程。

如果想让某进程(Thread)的优先级比其它进程(Process)中的线程(Thread)高,那边必须提升进程(Process)的优先级。

  • 使用System.Diagnostics下的Process类。

    using (Process p = Process.GetCurrentProcess())
        p.PriorityClass = ProcessPoiorityClass.High;
    

这适用于只做少量公共,且需要较低延迟的非UI进程。对于需要大量计算的应用程序,尤其是有UI的应用程序,提高优先级可能会使其它进程饿死,从而降低整个计算机的速度。

信号 Singaling

有时候需要让某线程一直处于等待的状态,知道接收到其它线程发来的通知。这就叫做signaling(发送信号)。

最简单的信号结构是 ManualResetEvent

  • 调用它的 WaitOne 方法会阻塞当前的线程,直到另一个线程通过调用 Set 方法来开启信号。

    class Program
    {
        static void Main(string[] args)
        {
            var signal = new ManualResetEvent(false);
            
            new Thread(() =>
            {
                Console.WriteLine("Waiting for signal.....");
                signal.WaitOne();
                signal.Dispose();
                Console.WriteLine("Got signal!");
            }
            ).Start();
            
            Thread.Sleep(3000);
            signal.Set();  //打开了信号
        }
    }
    
  • 调用完Set之后,信号会于“打开”的状态。可以通过调用Reset方法将其再次关闭。

富客户端应用程序的线程

在WPF、UWP、WinForm等类型的程序中,如果主线程执行耗时的操作,就会导致整个程序无响应。因为主线程同时还需要处理消息循环,而渲染和鼠标键盘事件处理等工作都是消息循环来执行的。

针对这种比较耗时的操作,一种比较流行的做法是启用一个 worker 线程。在worker线程上执行这些比较耗时的操作,执行完操作后,再把结果更新到UI。

富客户端应用的线程模型通常是:

  • UI 元素和控件只能从创建它们的线程来进行访问(通常是主 UI 线程)
  • 当想从 worker 线程更新 UI 的时候,必须把请求交给 UI 线程。

比较底层的实现是:

  • 在WPF中,在元素的 Dispatcher 对象上调用 BeginInvoke 或 Invoke 方法。
  • 在WinForm中,调用控件的 BeginInvoke 或 Invoke 方法。
  • 在UWP中,调用 Dispatcher 对象上调用 RunAsync 或 Invoke 方法。

上述方法都接收一个委托:

  • BeginInvoke 和 RunAsync 通过将委托排队到UI线程的消息队列来执行工作
  • Invoke 执行相同的操作,但随后会进行阻塞,直到 UI 线程读取并处理消息
  • Invoke 允许从方法中获取返回值
    • 如果不需要返回值,BeginInvoke 和 RunAsync 更可取,因为它们不会阻塞调用方,也不会引入死锁的可能性。

如下程序,当运行“Thread.Sleep(5000)”时,程序会进入假死状态,UI会在5s中之内无法进行任何操作。

private void Button_Click(object sender, RoutedEventArgs e)
{
    Work();
}

void Work()
{
    Thread.Sleep(5000);
    TxtMessage.Text = "The answer";
}

当更改成如下代码时,程序运行“TxtMessage.Text = “The answer”;”会报错,因为TxtMessage 对象属于主线程,其它线程无法之间更新主线程(UI线程)上的对象 。

private void Button_Click(object sender, RoutedEventArgs e)
{
    new Thread(Work).Start();
}

void Work()
{
    Thread.Sleep(5000);
    TxtMessage.Text = "The answer";
}

当更改成如下代码时,程序不会假死,当运行“Thread.Sleep(5000)”后,依然可以对UI继续进行任何操作,直到5s后输出结果。

//WPF
public partial class MainWindow:Window
{
    public MainWindow()
    {
        InitializeComponent();
    }
    
    private void Button_Click(object sender, RoutedEventArgs e)
    {
        new Thread(Work).Start();
    }

    void Work()
    {
        Thread.Sleep(5000);
        UpdateMessage("The answer");
    }

    void UpdateMessage(string message)
    {
        Action action = () => TextMessage.Text = message;
        Dispatcher.BeginInvoke(action); //将委托排队发送到UI线程的消息队列来执行工作
    }
}

同步上下文(Synchronization Contexts)

在 System.ComponentModel 下有一个抽象类:SynchronizationContext,它使得 Thread Marshaling 得到泛化。

  • Marshaling :如将C#代码中的数据,转换为Json,即为Marshaling 。

  • Thread Marshaling:把一些数据的所有权,从一个线程交给了另外一个线程。

针对移动、桌面(WPF\WinForm\UWP)等富客户端应用的API,它们都定义和实例化了 SynchronizationContext 的子类

  • 当运行在UI线程时,可以通过静态属性 SynchronizationContext.Current 来获得;
  • 捕获该属性后,可以从worker线程向UI线程发送数据
  • 调用 Post 就相当于调用 Dispatcher 或 Control 上面的 BeginInvoke 方法
  • 还有一个 Send 方法,它等价于 Invoke 方法
//WPF
public partial class MainWindow:Window
{
    SynchronizationContext _uiSyncContext;
    
    public MainWindow()
    {
        InitializeComponent();
        //为当前 UI 线程捕获 Synchronization Context
        _uiSyncContext = SynchronizationContext.Current;
    }

    void Work()
    {
        Thread.Sleep(5000);  //模拟耗时操作
        UpdateMessage("The answer");
    }

    void UpdateMessage(string message)
    {
        //把委托 Marshal 给 UI 线程
        _uiSyncContext.Post(_ => txtMessage.Text = message, null);
        //调用 Post 就相当于调用 Dispatcher 或 Control 上面的 BeginInvoke 方法
    }
}

线程池(Thread Pool)

当开始一个线程的时候,将花费几百微秒来组织内容,比如准备一个新的局部变量栈。

线程池可以节省这种开销:

  • 通过预先创建一个可循环使用线程的“池子”来减少这一开销。

线程池对于高效的并行编程和细粒度并发时必不可少的。

使用线程池线程需要注意的几点:

  • 不可以设置池线程的 Name
  • 池线程都是后台线程
  • 阻塞池线程可使性能降级

使用池线程可以自由更改池线程的优先级,当它释放回线程池的时候,优先级将还原为正常状态。

可以通过 Thread.CurrentThread.IsThreadPoolThread 属性来判断是否执行在池线程上。

进入线程池

最简单的、显式的在池线程运行代码的方式就是使用 Task.Run

//Task is in System.Threading.Tasks
Task.Run(() => Console.WriteLine("Hello from the thread pool"));
谁使用了线程池
  • WCF、Remoting、ASP.NET、 ASMX Web Services 应用服务器
  • System.Timers.Timer、System.Threading.Timer
  • 并行编程结构
  • BackgroundWorker 类(现在很多余)
  • 异步委托(现在很多余)
线程池中的整洁

线程池提供了另一个功能,即确保临时超出 Compute-Bound 的工作不会导致 CPU 超额订阅。

CPU 超额订阅:活跃的线程超过 CPU 的核数,操作系统就需要对线程进行时间切片。

超额订阅对性能影响很大,时间切片需要昂贵的上下文切换,并且可能使 CPU 缓存失效,而 CPU 缓存对现代处理器的性能至关重要。

CLR 的策略
  • CLR 通过对任务排队并对其启动进行节限制来避免线程池中的超额订阅
  • 它首先运行尽可能多的并发任务(只要还有CPU 核),然后通过爬山算法跳转并发级别,并在特定方向上不断调整工作负载。
    • 如果吞吐量提高,它将继续朝同一方向(否则将反转)。
  • 这确保它始终追随最佳性能曲线,即面对计算机上竞争的进行活动时,也是如此。
  • 如果下面两点能够满足,那么CLR的策略将会发挥出最佳效果:
    • 工作项大多是短时间运行的(< 250毫秒,或者理想情况下 < 100毫秒),因此 CLR 有很多机会进行测量和调整。
    • 大部分时间都被阻塞的工作向不会主宰线程池。

总结:如果向充分利用CPU,那么保持线程池的“整洁”时非常重要的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值