內核態,用戶態,線程,進程,協程基本概念不再贅述。
原生線程和用戶線程
原生線程
在內核態中創建的線程,只服務于內核態
用戶線程
由User Application創建的線程,該線程會在內核態與用戶態中間來回穿梭
比如Throw Exception,就會由CLR 線程觸發,從用戶態切換到內核態,再切換回用戶態。
時鐘中斷與時間片
時鐘中斷的底層,是由主板上的硬件定時器產生,以固定的時間間隔(15.6ms)觸發。windows作為消費端,來處理多線程任務調度/定時任務。
操作系統獲取到中斷后,再自行分配時間片,每個線程在一個時間片里獲得CPU的運行時間,等時間片用完后,再由操作系統分配給下一個線程
windows 客戶端一個時間片為 2個時鐘中斷 (15.6*2=31.5ms)
windows 服務端一個時間片為 12個時鐘中斷 (15.6*12=187.2ms,主要是為了更高的吞吐量)
CLR via C# 一文中說windows每30ms切換一次就是這個原因。
當一個線程時間片用完后,操作系統會將新的時間片轉移給其它線程。以實現“多線程”效果。
單個核心在同一時間只能處理一個線程的任務。
眼見為實
- 中斷多久觸發一次?
使用windbg進入內核態,使用nt!KeMaximumIncrement命令查看看它的值

注意,單位為100ns,因此156250*100/1000/1000=15.625ms
Windows下CPU核的數據結構
Windows會給每一個 CPU核 分配一個_KPCR的內存結構,用來記錄當前CPU的狀態。并拓展了_KPRCB來記錄更多信息。
關鍵信息就是存儲著 CuurentThread/NextThread/IdleThread(空閑線程)
眼見為實
使用dt命令來查看
dt命令是一個非常有用的顯示類型信息的工具,主要用于查看和分析數據結構的布局和內容


CPU當前正在執行哪個線程?
使用!running命令,可以看到當前 CPU核 正在執行的線程
本質上就是對_KPCR/_KPRCB的提煉簡化,

Windows下線程的數據結構
每個線程都有以下要素,這是創建線程無法避免的開銷。
線程內核對象(Thread Kernel Object)
OS中創建的每一個線程都會分配數據結構來承載描述信息
Windows會給每一個 Thread 分配一個_ETHREAD的內存結構,用來記錄當前線程的狀態,其中就包括了線程上下文(Thread Context)
線程環境塊(Thread Environment Block, TEB)
TEB是在用戶態中分配的內存塊,主要包括線程的Exception,Local Storage等信息
用戶態線程棧(User-Mode Stack)
我們常說的棧空間就是指的這里,大名鼎鼎的OOM就出自于此
內核態線程棧(Kernel-Mode Stack)
處于安全隔離考慮,在內核態中復制了一個同樣的??臻g。用來處理用戶態訪問內核態的代碼。
眼見為實
線程內核對象
使用命令dt nt!_ETHREAD

TEB
使用命令dt nt!_TEB

線程上下文切換的本質
上下文切換的本質就是,備份被切換線程寄存器的值,到該線程的上下文中。再從切換后的線程中,讀取上下文到寄存器中。
舉個簡單的例子就是,我跟你輪流打游戲,我玩的時候要先加載我的存檔,輪到你玩的時候,我再保存我的存檔。你玩的時候重復這一過程。
線程切換的成本
上下文切換是凈開銷,不會帶來任何性能上的收益。因此優化程序的一個思路就是降低上下文切換
顯式成本
保存寄存器的值到內存,從內存讀取寄存器。
寄存器的數量越多成本就越高,以AMD 7840HS處理器為例,總共有17個寄存器

隱式成本
如果線程切換是在同一個進程中,它們共享用戶態的虛擬內存空間。所以當線程切換的時候,就有可能命中CPU的緩存(比如線程之間共享的變量,代碼)。
如果在不同的進程中,線程的切換則會導致用戶態的虛擬內存空間都失效,進而導致CPU緩存失效。
眼見為實
說了這么多理論,不如直接看源碼。
/*主代碼入口*/
PUBLIC KiSwapContext
.PROC KiSwapContext
/* Generate a KEXCEPTION_FRAME on the stack */
/* 核心邏輯:把寄存器全部備份一遍 */
GENERATE_EXCEPTION_FRAME
/* Do the swap with the registers correctly setup */
/* 將新線程的地址,交換到R8寄存器上 */
mov r8, gs:[PcCurrentThread] /* Pointer to the new thread */
call KiSwapContextInternal
/* Restore the registers from the KEXCEPTION_FRAME */
/* 把之前保存的寄存器值恢復到CPU寄存器 */
RESTORE_EXCEPTION_STATE
/* Return */
ret
.ENDP
MACRO(GENERATE_EXCEPTION_FRAME)
/* Allocate a KEXCEPTION_FRAME on the stack */
/* -8 because the last field is the return address */
sub rsp, KEXCEPTION_FRAME_LENGTH - 8
.allocstack (KEXCEPTION_FRAME_LENGTH - 8)
/* Save non-volatiles in KEXCEPTION_FRAME */
mov [rsp + ExRbp], rbp
.savereg rbp, ExRbp
mov [rsp + ExRbx], rbx
.savereg rbx, ExRbx
mov [rsp +ExRdi], rdi
.savereg rdi, ExRdi
mov [rsp + ExRsi], rsi
.savereg rsi, ExRsi
......省略
ENDM
MACRO(RESTORE_EXCEPTION_STATE)
/* Restore non-volatile registers */
mov rbp, [rsp + ExRbp]
mov rbx, [rsp + ExRbx]
mov rdi, [rsp + ExRdi]
mov rsi, [rsp + ExRsi]
mov r12, [rsp + ExR12]
mov r13, [rsp + ExR13]
mov r14, [rsp + ExR14]
mov r15, [rsp + ExR15]
movaps xmm6, [rsp + ExXmm6]
......省略
/* Clean stack and return */
add rsp, KEXCEPTION_FRAME_LENGTH - 8
ENDM

https://github.com/reactos/reactos/blob/master/ntoskrnl/ke/amd64/ctxswitch.S
線程調度模型(究極簡化版)
在上面說到的邏輯核數據結構_KPRCB中,有三個屬性。
單鏈表的DeferredReadyListHead,雙鏈表的WaitListHead, 二維數組形態的DispatcherReadyListHead。

簡單來說,當線程切換時,邏輯核從DispatcherReadyListHead根據線程優先級切換高優先級線程。如果線程主動放棄了時間片(thread.yield/thread.sleep),則會把線程放入DeferredReadyListHead。WaitListHead則用于存放那些正在等待某些事件發生的線程,如等待 I/O 操作完成、等待某個信號量或者等待互斥體等
眼見為實
直接看源碼

可以看到DispatcherReadyListHead大小為32,主要是因為windows將線程優先級設為了0-31不同的級別。
https://github.com/reactos/reactos/blob/master/sdk/include/ndk/amd64/ketypes.h
線程優先級
Windows\Linux作為搶占式操作系統,無法保證線程一直運行。因此使用線程優先級來讓用戶有一定的控制權。
windows每個線程都有0(最低)~31(最高)的優先級,存儲在DispatcherReadyListHead中,OS為線程分配時間片時,就是優先為高優先級線程分配時間.
只要一直存在31優先級的線程,就永遠不可能調用0~30優先級的線程。這稱為“線程饑餓”
Linux 使用 nice 值來表示優先級,范圍是從 - 20 到 19。nice 值越小,優先級越高,默認的 nice 值是 0
C#線程結構模型
C#線程的底層是CLR托管線程,而CLR的承載是操作系統線程。因此它們都有一一對應的關系。

分別對應C#線程(Thread.CurrentThread.ManagedThreadId),CLR線程,OS線程
線程在創建過程中會經歷兩個階段
static void Main(string[] args)
{
var testThread = new Thread(DoWork);
testThread.Start();
}

clr 保留了Lowest,BelowNormal,Normal,AboveNormal,Highest 5個線程優先級
前臺線程與后臺線程
注意,這僅僅是CLR的概念,在OS層面是沒有此概念的。
前臺線程:適用于關鍵性任務,進程會等待所有前臺線程執行完畢后,才會正常退出。Thread默認是前臺線程
后臺線程:適用于非關鍵性任務,進程不會等待后臺線程執行完畢,直接退出。ThreadPool默認是后臺線程
思考一個問題,托管線程調用非托管代碼,非托管代碼調用托管代碼。它們用什么線程來調用?
前者取決于線程創建方式(Thread/ThreadPool),后者為后臺線程,因為native thread要綁定managed thread,由線程池創建
協程與虛擬線程
目前.NET 9 還不支持該特性
https://steven-giesel.com/blogPost/59752c38-9c99-4641-9853-9cfa97bb2d29
線程本地存儲(Thread Local Storage, TLS)
TLS用于實現按照線程隔離的線程本地變量,其修改的值只對修改的線程可見。
原生實現
OS原生支持TLS,比如在windows上通過TlsAlloc/TlsGetValue/TlsSetValue實現對TLS數據的分配/修改/賦值
OS使用分段寄存器(比如gs寄存器)存儲指向TLS數據的地址。利用上下文切換機制,每個native thread可以獨立訪問gs寄存器。進而定位到關聯的TLS
.Net實現
C#的TLS是基于C++做的封裝,TLS中只存儲了一個ThreadLocalInfo對象,最后借助它與Thread的關聯。來得知存儲在托管堆中的線程本地變量
兩者總體思路都是使用一段內存來存儲本地變量,當線程切換時,切換本地變量存儲地址的指針。
眼見為實
使用~命令得出每個線程棧的范圍

在使用!teb 觀察其內存布局,可以看到TLS Storage指向的內存空間,其訪問模式為BaseAddress+偏移量模式

ThreadStatic Attribute底層實現

在.NET中,原生線程通過ThreadLocalInfo來關聯托管線程的mapping關系,托管線程關聯TLB(Thread Local lock)表,TLB再關聯TLM(Thread Local Module),TLM再關聯托管堆,托管堆中才是存儲.NET TLS真正的地方。
使用ThreadLocal的理由
internal class Program
{
[ThreadStatic]
public static Person _person = new Person() { Age = 18 };
static void Main(string[] args)
{
Task.Run(() =>
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine($"ThreadId={Thread.CurrentThread.ManagedThreadId},{_person.Age++}");
Thread.Sleep(1000);
}
});
Task.Run(() =>
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine($"ThreadId={Thread.CurrentThread.ManagedThreadId},{_person.Age++}");
Thread.Sleep(1000);
}
});
Console.ReadLine();
}
}
public class Person
{
public int Age;
}
小伙伴可以運行一段這段代碼,就知道為什么要使用ThreadLocal而不使用ThreadStatic Attribute。
tips:靜態構造函數只能運行一次。
AsyncThreadLocal
public class AsyncLocalDemo
{
private static ThreadLocal<int> tls = new ThreadLocal<int>();
private static AsyncLocal<int> asyncTls=new AsyncLocal<int>();
public async Task Example(int i,int j)
{
Console.WriteLine($"current thread Id={Thread.CurrentThread.ManagedThreadId}");
tls.Value = i;
asyncTls.Value = j;
await Task.Delay(1000);
Console.WriteLine($"current thread Id={Thread.CurrentThread.ManagedThreadId}");
Console.WriteLine($"tls={tls.Value}");
Console.WriteLine($"asyncTls={asyncTls.Value}");
}
}

運行此段代碼,會發現TLS失效。其原因是: 線程1持有的TLS,在經過await后,接著往下處理的線程變為線程11,線程11并不能讀取到線程1的TLS,所以會失效
AsyncLocal原理
為什么AsyncLocal能成功呢?上源碼
public sealed class AsyncLocal<T> : IAsyncLocal
{
private readonly Action<AsyncLocalValueChangedArgs<T>>? _valueChangedHandler;
public AsyncLocal(Action<AsyncLocalValueChangedArgs<T>>? valueChangedHandler)
{
_valueChangedHandler = valueChangedHandler;
}
public T Value
{
get
{
object? value = ExecutionContext.GetLocalValue(this);
if (typeof(T).IsValueType && value is null)
{
return default;
}
return (T)value!;
}
set
{
ExecutionContext.SetLocalValue(this, value, _valueChangedHandler is not null);
}
}
}
可以看到,AsyncLocal本身邏輯很簡單。其核心是使用ExecutionContext實現get/set,那么ExecutionContext究竟是何方神圣呢?
C#中每一個線程都會綁定一個ExecutionContext,可以使用Thread.CurrentThread.ExecutionContext來查看。
理想情況下,當一個線程使用另一個線程執行任務時,前者的執行ExecutionContext會被copy到后者中來。這個過程被稱為上下文流動
因此,AsyncLocal能夠執行成功秘訣就在于,當線程切換的時候,線程1所存儲AsyncTLS流動到了到了線程11。因此線程11能夠讀取到線程1的值。
眼見為實:上下文流動
public sealed partial class Thread : CriticalFinalizerObject
{
private void Start(bool captureContext, bool internalThread = false)
{
ThrowIfNoThreadStart(internalThread);
StartHelper? startHelper = _startHelper;
if (startHelper != null)
{
startHelper._startArg = null;
startHelper._executionContext = captureContext ? ExecutionContext.Capture() : null;
}
StartCore();
}
}
public sealed class ExecutionContext : IDisposable, ISerializable
{
public static ExecutionContext? Capture()
{
ExecutionContext? executionContext = Thread.CurrentThread._executionContext;
if (executionContext == null)
{
executionContext = Default;
}
else if (executionContext.m_isFlowSuppressed)
{
executionContext = null;
}
return executionContext;
}
}
https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Threading/AsyncLocal.cs
https://github.com/dotnet/runtime/blob/master/src/libraries/System.Private.CoreLib/src/System/Threading/ExecutionContext.cs
TLS為何不會內存泄露?
眾所周知,在JAVA的世界中。使用TLS,如果不及時釋放是會造成內存泄露的。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ThreadLocalMemoryLeakExample {
private static final ThreadLocal<int[]> threadLocal = new ThreadLocal<>();
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(3);
for (int i = 0; i < 10; i++) {
executorService.submit(() -> {
int[] largeArray = new int[10000];
threadLocal.set(largeArray);
});
}
executorService.shutdown();
executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.DAYS);
}
}
簡單來說,就是線程池的復用會導致Thread一直存在引用關系,因此線程不會銷毀。而線程不銷毀,因此一直保持著對ThreadLocal的引用。所以GC就不會釋放這個對象,隨著線程被不斷復用,ThreadLocal慢慢累積,從而導致內存泄露。
在C#的世界中,ThreadLocal并不會造成內存泄露,主要仰仗Disposable模式,讓我們在源碼中一探究竟。
public class ThreadLocal<T> : IDisposable
{
private Func<T>? _valueFactory;
[ThreadStatic]
private static LinkedSlotVolatile[]? ts_slotArray;
private static readonly IdManager s_idManager = new IdManager();
[ThreadStatic]
private static FinalizationHelper? ts_finalizationHelper;
public T Value
{
get
{
}
set
{
SetValueSlow(value, slotArray);
}
}
private void SetValueSlow(T value, LinkedSlotVolatile[]? slotArray)
{
if (slotArray == null)
{
slotArray = new LinkedSlotVolatile[GetNewTableSize(id + 1)];
ts_finalizationHelper = new FinalizationHelper(slotArray);
ts_slotArray = slotArray;
}
if (slotArray[id].Value == null)
{
CreateLinkedSlot(slotArray, id, value);
}
}
protected virtual void Dispose(bool disposing)
{
for (LinkedSlot? linkedSlot = _linkedSlot._next; linkedSlot != null; linkedSlot = linkedSlot._next)
{
linkedSlot._slotArray = null;
slotArray[id].Value!._value = default;
slotArray[id].Value = null;
}
}
}