C#多线程(一)线程基础篇

线程基础

视频已经发布到B站

如果你已经掌握了线程基础,请跳往下一篇:同步基础传送门

参考文章:

《Threading in C# 》(Joseph Albahari)https://www.albahari.com/threading/

《Threading in C# 》中文翻译(GKarch ):https://blog.gkarch.com/topic/threading.html

《图解系统》(小林coding):https://xiaolincoding.com/os/

一、概念

并行(parallel):同一时间,多个线程/进程同时执行。多线程的目的就是为了并行,充分利用cpu多个核心,提高程序性能

线程(threading):线程是操作系统能够进行 运算调度的最小单位,是进程的实际运作单位。
一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并行多个线程,每条线程并行执行不同的任务。

进程(process):进程是操作系统进行资源分配的基本单位。多个进程并行的在计算机上执行,多个线程并行的在进程中执行,
进程之间是隔离的,线程之间共享堆,私有栈空间。

CLR 为每个线程分配各自独立的 栈(stack) 空间,因此局部变量是线程独立的。

static void Main()
{
  new Thread(Go).Start();  // 在新线程执行Go()
  Go();  // 在主线程执行Go()
}

static void Go()
{
  // 定义和使用局部变量 - 'cycles'
  for (int cycles = 0; cycles < 5; cycles++) Console.Write ('?');
}

变量cycles的副本是分别在线程各自的栈中创建,因此会输出 10 个问号

??????????

线程可以通过对同一对象的引用来共享数据。例如:

static bool done = false;

static void Main()
{
  new Thread (tt.Go).Start(); // A
  Go(); // B
}

static void Go()
{
   if (!done) { 
      Console.WriteLine ("Done");
      done = true;
   }
}

这个例子引出了一个关键概念 线程安全(thread safety) ,由于并发,” Done “ 有可能会被打印两次

通过简单的加锁操作:在读写公共字段时,获得一个 排它锁(互斥锁,exclusive lock ) ,c#中使用lock即可生成 临界区(critical section)

static readonly object locker = new object();
...
static void Go()
{
  lock (locker) // B
  {
    if (!done) { 
      Console.WriteLine ("Done");
      done = true;
    }
  }
}

临界区(critical section):在同一时刻只有一个线程能进入,不允许并发。当有线程进入临界区段时,其他试图进入的线程或是进程必须 等待或阻塞(blocking)

线程阻塞(blocking):指一个线程在执行过程中暂停,以等待某个条件的触发来解除暂停。阻塞状态的线程不会消耗CPU资源

挂起(Suspend):和阻塞非常相似,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换入到物理内存。描述进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态

可以通过调用Join方法等待线程执行结束,例如:

static void Main()
{
  Thread t = new Thread(Go);
  t.Start();
  t.Join();  // 等待线程 t 执行完毕
  Console.WriteLine ("Thread t has ended!");
}

static void Go()
{
  for (int i = 0; i < 1000; i++) Console.Write ("y");
}

也可以使用Sleep使当前线程阻塞一段时间:

Thread.Sleep (500);  // 阻塞 500 毫秒

Thread.Sleep(0)会立即释放当前的时间片(time slice),将 CPU 资源出让给其它线程。Framework 4.0的Thread.Yield()方法与其大致相同,不同的是Yield()只会出让给运行在相同处理器核心上的其它线程。

Sleep(0)和Yield在调整代码性能时偶尔有用,它也是一个很好的诊断工具,可以用于找出线程安全(thread safety)的问题。如果在你代码的任意位置插入Thread.Yield()会影响到程序,
基本可以确定存在 bug。

二、原理

硬件结构

https://xiaolincoding.com/os/1_hardware/how_cpu_run.html#图灵机的工作方式

运行时

  线程在内部由一个 线程调度器(thread scheduler) 管理,一般 CLR 会把这个任务交给操作系统完成。线程调度器确保所有活动的线程能够分配到适当的执行时间,并且保证那些处于等待或阻塞状态(例如,等待排它锁或者用户输入)的线程不消耗CPU时间。

  在单核计算机上,线程调度器会进行 时间切片(time-slicing) ,快速的在活动线程中切换执行。在 Windows 操作系统上,一个时间片通常在十几毫秒(译者注:默认 15.625ms),远大于 CPU 在线程间进行上下文切换的开销(通常在几微秒区间)。

  在多核计算机上,多线程的实现是混合了时间切片和 真实的并发(genuine concurrency) ,不同的线程同时运行在不同的 CPU 核心上。仍然会使用到时间切片,因为操作系统除了要调度其它的应用,还需要调度自身的线程。

  线程的执行由于外部因素(比如时间切片)被中断称为 被抢占(preempted)。在大多数情况下,线程无法控制其在什么时间,什么代码块被抢占。

  多线程同样也会带来缺点,最大的问题在于它提高了程序的复杂度。使用多个线程本身并不复杂,复杂的是线程间的交互(共享数据)如何保证安全。无论线程间的交互是否有意为之,都会带来较长的开发周期,以及带来间歇的、难以重现的 bug。因此,最好保证线程间的交互尽可能少,并坚持简单和已被证明的多线程交互设计。

  当频繁地调度和切换线程时(且活动线程数量大于 CPU 核心数),多线程会增加系统资源和 CPU 的开销,线程的创建和销毁也会增加开销。多线程并不总是能提升程序的运行速度,如果使用不当,反而可能降低速度。

三、基础

创建与启动

使用Thread类的构造方法来创建线程,支持以下两种委托

public delegate void ThreadStart();

public delegate void ParameterizedThreadStart (object? obj);

关于Thread构造重载方法参数 maxStackSize,不建议使用

https://stackoverflow.com/questions/5507574/maximum-thread-stack-size-net

public void 创建一个线程()
{
    var t = new Thread(Go);  // 开一个线程t
    t.Start();  // 启动t线程,执行Go方法
    
    Go();  // 主线程执行Go方法
}

void Go()
{
    _testOutputHelper.WriteLine("hello world!");
}

每一个线程都有一个 Name 属性,我们可以设置它以便于调试。

public void 线程命名()
{
    var t = new Thread(Go);  // 开一个线程t
    t.Name = "worker";
    t.Start();  // 启动t线程,执行Go方法
    
    Go();  // 主线程执行Go方法
}

void Go()
{
    // Thread.CurrentThread属性会返回当前执行的线程
    _testOutputHelper.WriteLine(Thread.CurrentThread.Name + " say: hello!");
}

传递参数

Thread类的Start方法重载支持向thread实例传参

public void Start(object? parameter)

参数被lambda表达式捕获,传递给Go方法

public void 创建一个线程()
{
    var t = new Thread(msg => Go(msg));  // 开一个线程t
    t.Start("hello world!");  // 启动t线程,执行Go方法
    
    Go("main thread say:hello world!");  // 主线程执行Go方法
}

void Go(object? msg)
{
    _testOutputHelper.WriteLine(msg?.ToString());
}

请务必注意,不要在启动线程之后误修改被捕获变量(captured variables)

public void 闭包问题()
{
    for (int i = 0; i < 10; i++)
    {
        new Thread (() => Go(i)).Start();
    }
}

前台/后台线程

默认情况下,显式创建的线程都是前台线程(foreground threads)。只要有一个前台线程在运行,程序就可以保持存活不结束。
当一个程序中所有前台线程停止运行时,仍在运行的所有后台线程会被强制终止。

这里说的 显示创建,指的是通过new Thread()创建的线程

非默认情况,指的是将Thread的IsBackground属性设置为true

static void Main (string[] args)
{
	Thread worker = new Thread ( () => Console.ReadLine() );
	if (args.Length > 0) worker.IsBackground = true;
	worker.Start();
}

当进程以强制终止这种方式结束时,后台线程执行栈中所有finally块就会被避开。如果程序依赖finally(或是using)块来执行清理工作,例如释放数据库/网络连接或是删除临时文件,就可能会产生问题。
为了避免这种问题,在退出程序时可以显式的等待这些后台线程结束。有两种方法可以实现:

  • 如果是显式创建的线程,在线程上调用Join阻塞。
  • 如果是使用线程池线程,使用信号构造,如事件等待句柄。

在任何一种情况下,都应指定一个超时时间,从而可以放弃由于某种原因而无法正常结束的线程。这是后备的退出策略:我们希望程序最后可以关闭,而不是让用户去开任务管理器(╯-_-)╯╧══╧

线程的 前台/后台状态 与它的 优先级/执行时间的分配无关。

异常处理

当线程开始运行后,其内部发生的异常不会抛到外面,更不会被外面的try-catch-finally块捕获到。

void 异常捕获()
{
    try
    {
        new Thread(Go).Start();  // 启动t线程,执行Go方法
    }
    catch (Exception e)
    {
        _testOutputHelper.WriteLine(e.Message);
    }
}
    
void Go() => throw null!;  // 抛出空指针异常

解决方案是将异常处理移到Go方法中:自己的异常,自己解决

static void Go()
{
  try
  {
    // ...
    throw null;    // 异常会在下面被捕获
    // ...
  }
  catch (Exception ex)
  {
    // 一般会记录异常,或通知其它线程我们遇到问题了
    // ...
  }
}

AppDomain.CurrentDomain.UnhandledException 会对所有未处理的异常触发,因此它可以用于集中记录线程发生的异常,但是它不能阻止程序退出。

void UnhandledException()
{
    AppDomain.CurrentDomain.UnhandledException += HandleUnHandledException;
    new Thread(Go).Start();  // 启动t线程,执行Go方法
}

void HandleUnHandledException(object sender, UnhandledExceptionEventArgs eventArgs)
{
    _testOutputHelper.WriteLine("我发现异常了");
}

并非所有线程上的异常都需要处理,以下情况,.NET Framework 会为你处理:

  • 异步委托(APM)
  • BackgroundWorker(EAP)
  • 任务并行库(TPL)

中断与中止

所有阻塞方法Wait(), Sleep() or Join(),在阻塞条件永远无法被满足且没有指定超时时间的情况下,线程会陷入永久阻塞。

有两个方式可以实现强行结束:中断、中止

中断(Interrupt)

在一个阻塞线程上调用Thread.Interrupt会强制释放它,并抛出ThreadInterruptedException异常,与上文的一样,这个异常同样不会抛出

var t = new Thread(delegate()
{
    try
    {
        Thread.Sleep(Timeout.Infinite);  // 无期限休眠
    }
    catch (ThreadInterruptedException)
    {
        _testOutputHelper.WriteLine("收到中断信号");
    }

    _testOutputHelper.WriteLine("溜溜球~");
});
t.Start();
Thread.Sleep(3000);  // 睡3s后中断线程t
t.Interrupt();

如果在非阻塞线程上调用Thread.Interrupt,线程会继续执行直到下次被阻塞时,抛出ThreadInterruptedException。这避免了以下这样的代码:

if ((worker.ThreadState & ThreadState.WaitSleepJoin) > 0)  // 线程不安全的
{
    worker.Interrupt();
}

  随意中断一个线程是极度危险的,这可能导致调用栈上的任意方法(框架、第三方包)收到意外的中断,而不仅仅是你自己的代码!只要调用栈上发生阻塞(因为使用同步构造),
中断就会发生在这,如果在设计时没有考虑中断(在finally块中执行适当清理),线程中的对象就可能成为一个奇怪状态(不可用或未完全释放)。

  如果是自己设计的阻塞,完全可以用 信号构造(signal structure) 或者 取消令牌(cancellation tokens) 来达到相同效果,且更加安全。如果希望结束他人代码导致的阻塞,Abort总是更合适

中止(Abort)

通过Thread.Abort方法也可以使阻塞的线程被强制释放,效果和调用Interrupt类似,不同的是它抛出的是ThreadAbortException的异常。另外,这个异常会在catch块结束时被重新抛出(试图更好的结束线程)。

Thread t = new Thread(delegate()
{
    try
    {
        while (true)
        {
        }
    }
    catch (ThreadAbortException)
    {
        _testOutputHelper.WriteLine("收到中止信号");
    }
    // 这里仍然会继续抛出ThreadAbortException,以保证此线程真正中止
});

_testOutputHelper.WriteLine(t.ThreadState.ToString()); // Unstarted 状态

t.Start();
Thread.Sleep(1000);
_testOutputHelper.WriteLine(t.ThreadState.ToString()); // Running 状态

t.Abort();
_testOutputHelper.WriteLine(t.ThreadState.ToString()); // AbortRequested 状态

t.Join();
_testOutputHelper.WriteLine(t.ThreadState.ToString()); // Stopped 状态

除非Thread.ResetAbort在catch块中被调用,在此之前,线程状态(thread state) 是AbortRequested,调用Thread.ResetAbort来阻止异常被自动重新抛出之后,
线程重新进入Running状态(从这开始,它可能被再次中止)

static void Main()
{
  Thread t = new Thread (Work);
  t.Start();
  Thread.Sleep (1000); t.Abort();
  Thread.Sleep (1000); t.Abort();
  Thread.Sleep (1000); t.Abort();
}

static void Work()
{
  while (true)
  {
    try { while (true); }
    catch (ThreadAbortException) { Thread.ResetAbort(); }
    Console.WriteLine ("我没死!");
  }
}

Thread.Abort在NET 5被弃用了:https://learn.microsoft.com/zh-cn/dotnet/core/compatibility/core-libraries/5.0/thread-abort-obsolete

未处理的ThreadAbortException是仅有的两个不会导致应用程序关闭的异常之一,另一个是AppDomainUnloadException。

Abort几乎对处于任何状态的线程都有效:Running、Blocked、Suspended以及Stopped。然而,当挂起的线程被中止时,会抛出ThreadStateException异常。中止会直到线程之后恢复时才会起作用。

try { suspendedThread.Abort(); }
catch (ThreadStateException) { suspendedThread.Resume(); }
// 现在 suspendedThread 才会中止

Interrupt和Abort最大的不同是:调用Interrupt线程会继续工作直到下次被阻塞时抛出异常,而调用Abort会立即在线程正在执行的地方抛出异常(非托管代码除外)。

这将导致一个新的问题:.NET Framework 中的代码可能会被中止,而且不是安全的中止。如果中止发生在FileStream被构造期间,很可能造成一个非托管文件句柄会一直保持打开直到应用程序域结束。

协作取消模式

正如上面所说Interrupt和Abort总是危险的,替代方案就是实现一个协作模式(cooperative ):工作线程定期检查一个用于指示是否应该结束的标识,发起者只需要设置这个标识,等待工作线程响应,即可取消线程执行。

Framework 4.0 提供了两个类CancellationTokenSourceCancellationToken来完成这个模式:

  • CancellationTokenSource定义了Cancel方法。
  • CancellationToken定义了IsCancellationRequested属性和ThrowIfCancellationRequested方法。
void 取消令牌()
{
    var cancelSource = new CancellationTokenSource();
    cancelSource.CancelAfter(3000);
    var t = new Thread(() => Work(cancelSource.Token));
    t.Start();
    t.Join();
}
void Work(CancellationToken cancelToken)
{
    while (true)
    {
        cancelToken.ThrowIfCancellationRequested();
        // ...
        Thread.Sleep(1000);
    }
}

四、异步编程模式

MSDN文档:https://learn.microsoft.com/zh-cn/dotnet/standard/asynchronous-programming-patterns/

异步编程模型(APM)

异步编程模型(Asynchronous Programming Model),提出于.NET Framework 1.x 的时代,基于IAsyncResult接口实现类似BeginXXX和EndXXX的方法。

APM是建立在委托之上的,Net Core中的委托 不支持异步调用,也就是 BeginInvoke 和 EndInvoke 方法。

void APM()
{
    var uri = new Uri("https://www.albahari.com/threading/part3.aspx");
    Func<Uri, int> f = CalcUriStringCount;
    var res = f.BeginInvoke(uri, null, null);
    // do something
    _testOutputHelper.WriteLine("我可以做别的事情");
    _testOutputHelper.WriteLine("共下载字符数:" + f.EndInvoke(res));
}
int CalcUriStringCount(Uri uri)
{
    var client = new WebClient();
    var res = client.DownloadString(uri);
    return res.Length;
}

EndInvoke会做三件事:

  1. 如果异步委托还没有结束,它会等待异步委托执行完成。
  2. 它会接收返回值(也包括refout方式的参数)。
  3. 它会向调用线程抛出未处理的异常。

不要因为异步委托调用的方法没有返回值就不调用EndInvoke,因为这将导致其内部的异常无法被调用线程察觉。MSDN文档中明确写了 “无论您使用何种方法,都要调用 EndInvoke 来完成异步调用。”

BeginInvoke也可以指定一个回调委托。这是一个在完成时会被自动调用的、接受IAsyncResult对象的方法。

BeginInvoke的最后一个参数是一个用户状态对象,用于设置IAsyncResultAsyncState属性。它可以是需要的任何东西,在这个例子中,我们用它向回调方法传递method委托,这样才能够在它上面调用EndInvoke

var uri = new Uri("https://www.albahari.com/threading/part3.aspx");
Func<Uri, int> func = CalcUriStringCount;
var res = func.BeginInvoke(uri, new AsyncCallback(res =>
{
    var target = res.AsyncState as Func<string, int>;
    _testOutputHelper.WriteLine("共下载字符数:" + target!.EndInvoke(res));
    _testOutputHelper.WriteLine("异步状态:" + res.AsyncState);
}), func);
// do something
_testOutputHelper.WriteLine("我可以做别的事情");
func.EndInvoke(res);

基于事件的异步模式(EAP)

基于事件的异步模式(event-based asynchronous pattern),EAP 是在 .NET Framework 2.0 中提出的,让类可以提供多线程的能力,而不需要使用者显式启动和管理线程。这种模式具有以下能力:

  • 协作取消模型(cooperative cancellation model)
  • 线程亲和性(thread affinity)
  • 将异常转发到完成事件(forwarding exceptions)

这个模式本质上就是:类提供一组成员,用于在内部管理多线程,类似于下边的代码:

// 这些成员来自于 WebClient 类:

public byte[] DownloadData (Uri address);    // 同步版本
public void DownloadDataAsync (Uri address);
public void DownloadDataAsync (Uri address, object userToken);
public event DownloadDataCompletedEventHandler DownloadDataCompleted;

public void CancelAsync (object userState);  // 取消一个操作
public bool IsBusy { get; }                  // 指示是否仍在运行

当调用基于EAP模式的类的XXXAsync方法时,就开始了一个异步操作,EAP模式是基于APM模式之上的。

var client = new WebClient();
client.DownloadStringCompleted += (sender, args) =>
{
    if (args.Cancelled) _testOutputHelper.WriteLine("已取消");
    else if (args.Error != null) _testOutputHelper.WriteLine("发生异常:" + args.Error.Message);
    else
    {
        _testOutputHelper.WriteLine("共下载字符数:" + args.Result.Length);
        // 可以在这里更新UI。。
    }
};
_testOutputHelper.WriteLine("我在做别的事情");
client.DownloadStringAsync(new Uri("https://www.albahari.com/threading/part3.aspx"));

BackgroundWorker是命名空间System.ComponentModel中的一个工具类,用于管理工作线程。它可以被认为是一个 EAP 的通用实现,在EAP功能的基础上额外提供了:

  • 报告工作进度的协议
  • 实现了IComponent接口

另外BackgroundWorker使用了线程池,意味着绝不应该在BackgroundWorker线程上调用Abort

void 工作进度报告()
{
    worker = new BackgroundWorker();
    worker.WorkerReportsProgress = true;  // 支持进度报告
    worker.WorkerSupportsCancellation = true;  // 支持取消
    worker.DoWork += DoWoker;
    worker.ProgressChanged += (_, args) => _testOutputHelper.WriteLine($"当前进度:{args.ProgressPercentage}%");
    worker.RunWorkerCompleted += (sender, args) =>
    {
        if (args.Cancelled) _testOutputHelper.WriteLine("工作线程已被取消");
        else if (args.Error != null) _testOutputHelper.WriteLine("工作线程发生异常: " + args.Error);
        else _testOutputHelper.WriteLine("任务完成,结果: " + args.Result); // Result来自DoWork
    };
    
    worker.RunWorkerAsync();
}

private void DoWoker(object? sender, DoWorkEventArgs e)
{
    for (int i = 0; i < 100; i+= 10)
    {
        if (worker.CancellationPending)
        {
            e.Cancel = true;
            return;
        }
        worker.ReportProgress(i);  // 上报进度
        Thread.Sleep(1000);  // 模拟耗时任务
    }

    e.Result = int.MaxValue;  // 这个值会回传给RunWorkerCompleted
}

基于任务的异步模式 (TAP)

从 .NET Framework 4 开始引入

五、拓展知识

小林coding:https://xiaolincoding.com/os/4_process/process_base.html#进程的控制结构

线程优先级

线程的Priority属性决定了相对于操作系统中的其它活动线程,它可以获得多少CPU 时间片(time slice)

优先级依次递增,在提升线程优先级前请三思,这可能会导致其它线程的 资源饥饿(resource starvation)

enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }

提升线程的优先级并不等于直接优先,因为线程还受进程优先级影响,因此还需要使用System.Diagnostics中的Process类

using (Process p = Process.GetCurrentProcess())
{
    p.PriorityClass = ProcessPriorityClass.High;
}

ProcessPriorityClass.High是一个略低于最高优先级Realtime的级别。将一个进程的优先级设置为Realtime是通知操作系统,我们绝不希望该进程将 CPU 时间出让给其它进程。
如果你的程序误入一个死循环,会发现甚至是操作系统也被锁住了,就只好去按电源按钮了o(>_<)o 正是由于这一原因,High 通常是实时程序的最好选择。

什么是进程退出?

进程退出一般出现在以下几种情况:

  • 正常退出,进程执行完任务。

  • 错误退出,进程遇到不可继续运行的错误(发生异常未捕获导致程序退出)

  • 被操作系统终止,进程本身有问题,比如进程企图访问不属于自己的内存地址

  • 被其它进程终止,比如通过资源管理器我们可以选择终止掉某个进程

以上只有前两种情况是进程自愿退出的,因此,总体上可以分为三类:进程自愿退出,操作系统终止进程以及进程终止进程。

main()执行结束时会自动隐式调用exit(),windows下叫ExitProcess。中止整个程序的执行,把控制返还给操作系统,并返回一个整数值,通常0表示正常终止,非0表示异常终止,这个值将会返回给操作系统。

windows中通过任务管理器,linux中通过kill去杀掉一个进程,其资源是否会释放?

会。进程的特征之一就是动态性,其生存周期就是产生到消亡。当发生进程终止后,调用进程终止原语,从PCB总线中将其删除,将PCB结构归还给系统,释放该进程的资源给其父进程或者操作系统。

但不完全会。如果用户强行终止了.NET 进程,所有线程都会被当作后台线程一般丢弃,有的资源没来得及释放,需要等待一段时间

Process类有以下两种方法:

  • CloseMainWindow:向主窗口消息循环发送wm_quit消息以请求关闭进程,这使程序有机会重新调用其子窗口和内核对象。
  • Kill:强制终止进程,就像在任务管理器中终止进程一样。

我们可以使用visual studio组件:内存分析器 分析发现几乎在所有情况下,kill速度更快,但通过检查实时内存图可以发现其“根引用”和“实例引用”释放的内存更少。

posted @ 2022-11-13 11:01  xiaolipro  阅读(5062)  评论(4编辑  收藏  举报