几个有意思的多线程问题 & 有趣现象笔记

队列消费者线程操作信号量释放的时候线程被带出的问题

在生产者-消费者场景下,如果消费者完成处理,并通过信号量发出通知时(比如通过 TaskCompleteSurce.SetResult()SemaphoreSlim.Release()),如果这个信号量被外部所传入,应该避免在队列自身的线程去释放信号量,因为队列线程在释放信号量时,队列线程自身会切换到等待信号量的代码块执行(对于 TaskCompleteSurce 就是 await,对于、SemaphoreSlim 就是 Wait),如果是一个很长的执行过程,那么原来的消费者线程由于被切外部执行,在执行完成前,是无处理后续消息的。

在队列调度中,不慎将 COM 组件的 STA 线程占用了,导致后续一些列奇怪的问题

公司一共现有程序,调用了某仪器厂商的库,该库底层又会和 COM+ 组件互操作。运行久了出现了奇怪问题(卡住,或者后续运行出现诡异问题,原因后面讲),VS附加调试时,弹出这个错误:

问题分析:

  • 前提:厂商库的某些接口,内部使用线程槽获取当前线程数据而非现代化的“变量形式上下文”,因此是不能够在多线程中被调用的,调用了不会直接报错,会因为出现多个线程槽上下文而出现各种古怪问题
  • 我们同事用了厂商的库,确实是单线程调用的,放在一个专用的后台任务线程,用队列消息泵,单线程处理。
  • 而问题出在队列处理完毕后的回调上,人们都会习惯处理完后,直接回调(调用 TaskCompletionSource.SetResult()
  • 但是在这里不行,直接回调回导致当前的线程被带入到外部去,由于调度的原因,导致2个问题
    • 回调执行结束,队列被唤醒后继续处理的时候,有可能已经不是原来的线程了,那么上面说的前提条件就无法满足了。
    • 原来的 STA 线程,不仅被带入到外部去,还一直被外部使用,或者被 .NET 归还到线程池休息,队列失去了该线程的持有,导致 COM 组件迟迟无法获取到原来的 STA 线程,于是出现了上图的CLR 无法从 COM 上下文 0x12f6d20 转换为 COM 上下文 0x12f6dd8,这种状态已持续 60 秒错误。

解决方式:

在等待 TaskCompletionSource.Task 的线程中,修改成:

原先是这样:
reportResult = reportToken.TaskCompletionSource.Task.ConfigureAwait().GetAwaiter().GetResult();

改成(注意区别,ConfigureAwait(false) ):
.ConfigureAwait(false).GetAwaiter().GetResult();
这里注意,.NET Core 默认对 task 的 await 已经是ConfigureAwait(false) ,但是 .NET Framework 不是,而这个项目是 .NET Framework 项目。

但是因为上面的修改依赖于调用方,如果调用方忘记设置ConfigureAwait(false) ,还是会出问题,所以再进一步,修改队列完成回调,不再使用当前队列的线程回调,开启新线程回调:

void SetTaskCompleteResult<T>(TaskCompletionSource<T> tsc, T result) {
    // 修改:COM 工作线程运行到这里,拿到结果后,从直接设置 TaskCompletionSource 修改成使用新线程设置:
    Task.Factory.StartNew(() => { tsc.SetResult(result); });
}

void SetTaskCompleteException<T>(TaskCompletionSource<T> tsc, Exception exception) {
    // 修改:COM 工作线程运行到这里,拿到结果后,从直接设置 TaskCompletionSource 修改成使用新线程设置:
    Task.Factory.StartNew(() => { tsc.SetException(exception); });
}

Thread.Abort()

.NET Framework 时代支持 Thread.Abort() 强制终止当前线程,但 .NET Core 时代这个接口已经被标记为过时且无效了(调用会发出 PlatformNotSupportedException),转而使用温和的 CancellationTokenSource 机制,“提醒”线程:你可以下班了!

所以,都是让线程下班,区别在于

  • Thread.Abort() 相当于直接拔掉线程的电源(拔掉之前会发车一个 ThreadAbortException 给线程的上次堆栈一个捕获终止的机会,并给它一点处理时间)
  • 可能因为 .NET Core 跨平台的原因,取消了 Thread.Abort() 的支持 https://learn.microsoft.com/en-us/dotnet/core/compatibility/core-libraries/5.0/thread-abort-obsolete
  • CancellationTokenSource 是会有一个变量能够让线程知道,我可以下班了,但是线程也可以选择继续加班。

Thread.Interrupt

实测 Thread.Interrupt() 能够打算 Sleep 中的线程,(该线程会收到 ThreadInterruptedException),但是执行中的线程不会被打断。

SpinWait

https://docs.microsoft.com/en-us/dotnet/api/system.threading.thread.spinwait?view=net-6.0

SpinWait 其实就是对 Thread.Sleep Thread.Yield Thread.SpinWait 的简单封装.
Thread.Sleep Thread.Yield 都会切线程,Thread.SpinWait 不会,即为自旋。
如果只是简单自旋,直接调用 Thread.SpinWait(1) 就可以。

ConfigureAwait 在 .NET Framework 和 .NET Core 的区别,以及 await 和 .GetAwaiter().GetResult(); 方式等待,会不会有区别?

.NET Framework

以下结论经过实际的测试:

  • .NET Framework 中 .ConfigureAwait(false) 有一定概率,线程id和之前的相等
  • 如果用 await .ConfigureAwait(false); 则是一定概率使用上一个线程
  • 如果用 .GetAwaiter().GetResult(); 则不论 true/false 一定是上一个线程

.NET Core

In .NET Core, Microsoft did away with the SynchronizationContext that caused us to need ConfigureAwait(false) everywhere. Thus your ASP.NET Core app technically doesn’t need any ConfigureAwait(false) logic in them because it’s redundant. However, if you have a library that is using .NET Standard, then it is highly recommended that you still use .ConfigureAwait(false). In .NET Core, this will effectively do nothing. But if someone with .NET Framework ends up using this library and calls it synchronously, they will be in big trouble.

在 .NET Core 中,由于调度机制修改,不再需要调用 ConfigureAwait(false) 方法。

单生产者多消费者 + 消费者因调用非可控外部库的原因特定情况会被外部库卡死,需要定时进行线程卡死检测,并在检测到后,重启消费者队列

背景&客观条件:

  • 生产者在事件回调(统一线程)中执行 BlockingCollection.Add
  • 多个消费者进行处理,执行的过程依赖了一个外部的代码库,这个外部库在某些情况下会卡死线程(且不支持取消,但这个外部库必须要用,你又没法改动它的代码

解决消费者被外部库卡死的办法:

  • 为 BlockingCollection 设置合理的 BoundedCapacity,如果检测到满了,说明要么还在正常处理中,要么所有 1~N个 消费者被卡死
  • 新增了消费者 Notify Alive 活跃报告机制,如果消费者还在处理(某些大数据是要处理很久),会定时更新 Notify Alive,这样外部就能知道他没被卡死
  • 此时,进行超时等待,如果Notify Alive 活跃报告机制不在状态且达到超时时间,认定该消息处理超时,将超时未完成的消息和消费者 TaskSource 记录到列表,方便后续进一步记录或处理
  • 如果达到 X个以上消费者线程被卡死,就等待当前未卡死的消费者任务完成后,终止当前 BlockingCollection 实例,重新开始队列

更新:
后来微软提供了 Channels 机制,直接封装了生产者消费者逻辑,我们就从使用原始的 BlockingCollection 实现迁移到了 Channels,不过仍然需要卡死检测机制。

posted @ 2024-11-16 16:20  darklx  阅读(3)  评论(0编辑  收藏  举报