【学习笔记】异步编程-C#

241 阅读4分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第12天,点击查看活动详情 >>

线程Thread

  • 线程是一个可执行路径,它可以独立于其他线程执行,每个线程都在操作系统的进程内执行,而操作系统提供了程序运行的独立环境
  • 在C#中线程的创建方式是var thread = new Thread(WriteY);,他必须传入一个执行任务,这个执行任务可以是一个函数(委托),然后可以设置它的名字,调用Start()来执行线程中的任务,在这里会看到X和Y交替输出,这是thread线程和主线程并发执行的效果。
namespace ThreadDemo
{
    class ThreadDemo
    {
        public static void Main(string[] args)
        {
            var thread = new Thread(WriteY);
            thread.Name = "Y Thread";
            thread.Start();
            for (int i = 0; i < 100; i++)
            {
                Console.Write("X");
            }
        }
        static void WriteY()
        {
            for (int i = 0; i < 100; i++)
            {
                Console.Write("Y");
            }
        }
    }
}
  • 线程的一些属性
    1. IsAlive:线程一旦开始执行,IsAlive就是true,线程结束就为false,结束的条件是传入的委托结束了执行
    2. Name:线程的名称,只能设置一次,一般用于调试。
    3. CurrentThread:返回当前执行的线程
    4. 注意:线程一旦结束,就无法再重启

Join和Sleep

  • Join:调用Join方法,就可以等待另一个线程结束,即thread2调用了Join方法后,其他的线程就会等待thread2执行结束后才能执行
class Program
{
    private static Thread thread1, thread2;
    static void Main(string[] args)
    {
        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执行结束后thread1才往下执行
            thread2.Join();
        Thread.Sleep(4000);
        Console.WriteLine("\nCurrent thread: {0}", Thread.CurrentThread.Name);
        Console.WriteLine("Thread1: {0}", thread1.ThreadState);
        Console.WriteLine("Thread2: {0}\n", thread2.ThreadState);
    }
}
  • Sleep:会暂停当前的线程,但线程不会被抢占
  • 添加超时:使用TimeSpan对象来设置Join的超时时间或设置Sleep的超时时间
public class JoinTimeSpan
{
    static TimeSpan waitTime = new TimeSpan(0, 0, 1);
    public static void Run()
    {
        var thread = new Thread(Work);
        thread.Start();
        if (thread.Join(waitTime))
        {
            Console.WriteLine("New thread terminated");
        }
        else
        {
            Console.WriteLine("Join timed out.");
        }
    }

    static void Work()
    {
        Thread.Sleep(waitTime);
    }
}
  • 注意:
    1. Thread.Sleep(0)这样调用会导致线程立即放弃当前的时间片,自动将cpu移交给其他线程。这个功能类似于Thread.Yield(),但是它只会把执行交给同一处理器上的其他线程。
    2. 当等待Sleep或Join的时候,线程处于阻塞的状态。
    3. Sleep(0)或Yield()在高级性能调试中是一个很好的诊断工具,有助于发现线程安全问题,但在生产代码中不能随意地使用Yield()函数。

阻塞

  • 如果线程的执行由于某种原因导致暂停,则就认为该线程被阻塞了。被阻塞的线程会立即将其处理器的时间片生成给其他线程,从此就不再消耗处理器的时间,直到满足其阻塞条件为止。
  • 可以使用ThreadState属性来判断线程是否处于被阻塞状态,ThreadState是一个枚举,可以用来获取线程的状态。它可以通过按位的形式合并状态的选项。

class Program
{
    static void Main(string[] args)
    {
        //按位进行或操作
        //比较有用的有四个状态:Unstarted、Running、WaitSleepJoin和Stopped
        var state = ThreadState.Unstarted | ThreadState.Stopped | ThreadState.WaitSleepJoin;
        Console.WriteLine($"{Convert.ToString((int)state, 2)}");

    }
}

解除阻塞Unblocking

  • 当遇到下列四种情况的时候,就会解除阻塞:
  1. 阻塞条件被满足
  2. 操作超时(需要设置超时条件)
  3. 通过Thread.Interrupt()进行打断
  4. 通过Thread.Abort()进行中止

上下文切换

  • 当线程阻塞时或接触阻塞时,操作系统将执行上下文切换,这回产生少量开销。

阻塞vs忙等待

  • 阻塞和忙等待也被称为IO-bound和Cpu-bound
  • IO-bound操作的工作方式有两种:
    1. 在当前线程上同步等待
    2. 异步的操作
1 2//例如 Console.ReadLine(), Thread.Sleep(), Thread.Join()...
  • 而Cpu-bound就类似于死循环
1 2 3 4 5//例如 while(true); //忙等待+阻塞 while(true) Thread.Sleep(100);

线程安全

本地状态和共享状态

  • Local本地独立:CLR为每个线程分配自己的内存栈,以便使本地变量保持独立。
  • Shared共享:
    1. 如果多个线程都引用到同一个对象实例,那么它们就共享了数据。
    2. 被lambda表达式或匿名委托所捕获的本地变量,会被编译器转化为字段,所以也会被共享。
    3. 静态字段也会在线程间共享。
  • 下面的_done字段就是共享变量
class Program
{
    private static bool _done;
    static void Main(string[] args)
    {
        new Thread(Go).Start();
        Go();
    }

    static void Go()
    {
        if (!_done)
        {
            _done = true;
            Console.WriteLine("Done");
        }
    }
}
  • 字段共享就会引出线程安全问题,因为上述例子的输出有可能不是固定的,如果在_done = true;前加上Thread.Sleep()结果就会不一样
class Program
{
    private static bool _done;
    static void Main(string[] args)
    {
        new Thread(Go).Start();
        Go();
    }

    static void Go()
    {
        if (!_done)
        {
            Thread.Sleep(100);
            _done = true;
            Console.WriteLine("Done");
        }
    }
}

lock

  • 在现实中应该尽量避免这种共享变量的使用,这种线程安全也可以用互斥锁lock来解决。
class Program
    {
        static readonly object _locker = new object();
        private static bool _done;
        static void Main(string[] args)
        {
            new Thread(Go).Start();
            Go();
        }

        static void Go()
        {
            lock (_locker)
            {
                if (!_done)
                {
                    Thread.Sleep(100);
                    _done = true;
                    Console.WriteLine("Done");
                }
            }
        }
    }
  • 在这里使用lock将共享代码块包括,这个lock代码块就称为临界区,每次只允许一个线程进入,在多线程上下文中,以这种方式避免不确定性的代码就叫做线程安全
  • 但是使用lock来解决线程安全也存在很大的问题,第一很容易忘记对字段加锁,第二会引起死锁。

线程传参

  • 可以直接使用lambda表达式做为Thread的参数,比如:
class Program
{
    static void Main(string[] args)
    {
        new Thread(() =>
                   {
                       Console.WriteLine("Hello World");
                   }).Start();
    }
}
  • 可以使用Thread的Start方法来传递任务的参数,因为传参委托的类型是ThreadStart,所以参数必须是object类型。
class Program
{
    static void Main(string[] args)
    {
        new Thread(G).Start("Hello World");
    }

    static void G(object str)
    {
        Console.WriteLine((string)str);
    }
}
  • 使用lambda表达式可以很简单地给Thread传递参数。但是线程开始后,可能会不小心修改了被捕获地变量,比如下面的例子,每一次运行都有不同的输出,而且输出有可能会有相同的值。
for (int i = 0; i < 10; i++)
{
    new Thread(() => Console.Write(i)).Start();
}
  • 可以使用局部变量来解决这个问题
for (int i = 0; i < 10; i++)
{
    int temp = i;
    new Thread(() => Console.Write(temp)).Start();
}

线程的优先级

  • 线程的优先级(Priority属性)决定了相对于操作系统中其他活跃线程所占的执行时间。
  • 如果想让某线程的优先级比其他进程中的线程高,那就必须提升进程的优先级,这里可以使用Process类
using (Process p = Process.GetCurrentProcess())
{
    p.PriorityClass = ProcessPriorityClass.High;
}
  • 但是,提高线程或进程的优先级可能会导致其他线程或线程处于饥饿状态,不能随便设置。

信号

  • 有时,你需要让某个线程一直处于等待状态,直到接收到其他线程发来的通知,才解除等待状态,这就叫做signaling。最简单的信号结构是MaunalResetEvent类对象。
  • 调用MaunalResetEvent类对象的WaitOne方法就会阻塞当前的线程,直到另一个线程通过调用Set方法来打开信号(发送信号)。
internal class Program
{
    private static void Main(string[] args)
    {
        var signal = new ManualResetEvent(false);

        var thread = new Thread(() =>
                                {
                                    Console.WriteLine("Waiting for signal...");
                                    //等待主线程发送的信号
                                    signal.WaitOne();
                                    signal.Dispose();
                                    Console.WriteLine("Got signal!");
                                });
        thread.Start();
        Thread.Sleep(3000);
        //向thread打开信号
        signal.Set();
    }
}
  • 信号打开后可以通过Reset方法来重新关闭信号。

同步上下文

  • Thread Marshaling:Marshaling的意思是假如要将一个平台上的数据发送给另一个平台,但是两个平台使用的数据格式不一致,这时候就需要把数据转化为可发送的数据格式,这就类似于json的序列化,而接收端就是Unmarshaling,也就类似于反序列化。而Thread Marshaling就是把一些数据的所有权从一个线程交给另外一个线程。
  • 在C#中同步上下文是用System.ComponentModel下的SynchronizationContext抽象类来实现的,可以通过实例化SynchronizationContext的子类,调用它的Post方法来实现同步上下文,这一般在富客户端应用比较常用(WPF、WinForm),它可以让主线程的一些事件的操作交给UI线程,这样UI线程可以做一下事件的调用,主线程也不会进入假死。

线程池(Thread Pool)

  • 线程池是一种多线程处理形式,处理过程中将任务添加到队列,然后在创建线程后自动启动这些任务。线程池线程都是后台线程。每个线程都使用默认的堆栈大小,以默认的优先级运行,并处于多线程单元中
  • 当开始一个线程的时候,将花费几百微秒来组织一个心得局部变量栈,而使用线程池可以节省这种组织开销,即通过预先创建一个可循环使用线程的池来减少这一开销。
  • 线程池对于高效的并行编程和细粒度并发是必不可少的,它允许在不被线程启动的开销淹没的情况下运行短期操作(这种短期操作开销很小)。

使用线程池需要注意的地方

  • 不可以设置线程池的Name
  • 池线程都是后台线程
  • 阻塞池线程可能使性能降级
  • 可以自由地更改池线程的优先级,当线程释放回池时优先级将还原
  • 可以使用Thread.CurrentThread.IsThreadPoolThread属性来判断是否执行在线程池上。