叫我安不理

.NET Core 线程(Thread)底层原理浅谈

简介

image
内核态,用户态,线程,进程,协程基本概念不再赘述。

原生线程和用户线程

  1. 原生线程
    在内核态中创建的线程,只服务于内核态

  2. 用户线程
    由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切换一次就是这个原因。

当一个线程时间片用完后,操作系统会将新的时间片转移给其它线程。以实现“多线程”效果。

单个核心在同一时间只能处理一个线程的任务。

眼见为实

  1. 中断多久触发一次?
    使用windbg进入内核态,使用nt!KeMaximumIncrement命令查看看它的值
    image

注意,单位为100ns,因此156250*100/1000/1000=15.625ms

Windows下CPU核的数据结构

Windows会给每一个 CPU核 分配一个_KPCR的内存结构,用来记录当前CPU的状态。并拓展了_KPRCB来记录更多信息。
关键信息就是存储着 CuurentThread/NextThread/IdleThread(空闲线程)

眼见为实

使用dt命令来查看

dt命令是一个非常有用的显示类型信息的工具,主要用于查看和分析数据结构的布局和内容
image
image

CPU当前正在执行哪个线程?

使用!running命令,可以看到当前 CPU核 正在执行的线程

本质上就是对_KPCR/_KPRCB的提炼简化,
image

Windows下线程的数据结构

每个线程都有以下要素,这是创建线程无法避免的开销。

  1. 线程内核对象(Thread Kernel Object)
    OS中创建的每一个线程都会分配数据结构来承载描述信息
    Windows会给每一个 Thread 分配一个_ETHREAD的内存结构,用来记录当前线程的状态,其中就包括了线程上下文(Thread Context)

  2. 线程环境块(Thread Environment Block, TEB)
    TEB是在用户态中分配的内存块,主要包括线程的Exception,Local Storage等信息

  3. 用户态线程栈(User-Mode Stack)
    我们常说的栈空间就是指的这里,大名鼎鼎的OOM就出自于此

  4. 内核态线程栈(Kernel-Mode Stack)
    处于安全隔离考虑,在内核态中复制了一个同样的栈空间。用来处理用户态访问内核态的代码。

眼见为实

  1. 线程内核对象
    使用命令dt nt!_ETHREAD
    image

  2. TEB
    使用命令dt nt!_TEB
    image

线程上下文切换的本质

上下文切换的本质就是,备份被切换线程寄存器的值,到该线程的上下文中。再从切换后的线程中,读取上下文到寄存器中。

举个简单的例子就是,我跟你轮流打游戏,我玩的时候要先加载我的存档,轮到你玩的时候,我再保存我的存档。你玩的时候重复这一过程。

线程切换的成本

上下文切换是净开销,不会带来任何性能上的收益。因此优化程序的一个思路就是降低上下文切换

  1. 显式成本
    保存寄存器的值到内存,从内存读取寄存器。
    寄存器的数量越多成本就越高,以AMD 7840HS处理器为例,总共有17个寄存器
    image

  2. 隐式成本
    如果线程切换是在同一个进程中,它们共享用户态的虚拟内存空间。所以当线程切换的时候,就有可能命中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

image

https://github.com/reactos/reactos/blob/master/ntoskrnl/ke/amd64/ctxswitch.S

线程调度模型(究极简化版)

在上面说到的逻辑核数据结构_KPRCB中,有三个属性。
单链表的DeferredReadyListHead,双链表的WaitListHead, 二维数组形态的DispatcherReadyListHead。
image

简单来说,当线程切换时,逻辑核从DispatcherReadyListHead根据线程优先级切换高优先级线程。如果线程主动放弃了时间片(thread.yield/thread.sleep),则会把线程放入DeferredReadyListHead。WaitListHead则用于存放那些正在等待某些事件发生的线程,如等待 I/O 操作完成、等待某个信号量或者等待互斥体等

眼见为实

直接看源码
image

可以看到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的承载是操作系统线程。因此它们都有一一对应的关系。
image
分别对应C#线程(Thread.CurrentThread.ManagedThreadId),CLR线程,OS线程

线程在创建过程中会经历两个阶段

        static void Main(string[] args)
        {
            var testThread = new Thread(DoWork);// 这个阶段只会在CLR中创建Thread,在OS上没有创建

            testThread.Start();//CLR底层会调用系统api,创建OS线程
        }

image

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用于实现按照线程隔离的线程本地变量,其修改的值只对修改的线程可见。

  1. 原生实现
    OS原生支持TLS,比如在windows上通过TlsAlloc/TlsGetValue/TlsSetValue实现对TLS数据的分配/修改/赋值
    OS使用分段寄存器(比如gs寄存器)存储指向TLS数据的地址。利用上下文切换机制,每个native thread可以独立访问gs寄存器。进而定位到关联的TLS

  2. .Net实现
    C#的TLS是基于C++做的封装,TLS中只存储了一个ThreadLocalInfo对象,最后借助它与Thread的关联。来得知存储在托管堆中的线程本地变量

两者总体思路都是使用一段内存来存储本地变量,当线程切换时,切换本地变量存储地址的指针。

眼见为实

使用~命令得出每个线程栈的范围
image
在使用!teb 观察其内存布局,可以看到TLS Storage指向的内存空间,其访问模式为BaseAddress+偏移量模式
image

ThreadStatic Attribute底层实现

image

在.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;
			
			//ExecutionContext.SuppressFlow();//阻止上下文流动,会导致AsyncLocal失效
            await Task.Delay(1000);

            Console.WriteLine($"current thread Id={Thread.CurrentThread.ManagedThreadId}");
            Console.WriteLine($"tls={tls.Value}");
            Console.WriteLine($"asyncTls={asyncTls.Value}");
        }
    }

image

运行此段代码,会发现TLS失效。其原因是: 线程1持有的TLS,在经过await后,接着往下处理的线程变为线程11,线程11并不能读取到线程1的TLS,所以会失效

AsyncLocal原理

为什么AsyncLocal能成功呢?上源码

    public sealed class AsyncLocal<T> : IAsyncLocal
    {
		//当value发生change时,注入一个委托,赋予它类似AOP的能力。
        private readonly Action<AsyncLocalValueChangedArgs<T>>? _valueChangedHandler;
		
        public AsyncLocal(Action<AsyncLocalValueChangedArgs<T>>? valueChangedHandler)
        {
            _valueChangedHandler = valueChangedHandler;
        }

        public T Value
        {
            get
            {
				//以自身为key,从ExecutionContext拿出值
                object? value = ExecutionContext.GetLocalValue(this);
                if (typeof(T).IsValueType && value is null)
                {
                    return default;
                }

                return (T)value!;
            }
            set
            {
				//以自身为key,从ExecutionContext设置值
                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;//关键点,captureContext为ture时,讲当前上下文复制给新线程
            	}

            	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 {

    // 创建一个ThreadLocal对象,用于存储每个线程独有的大对象(这里简单用一个数组模拟大对象)
    private static final ThreadLocal<int[]> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) throws InterruptedException {
        // 创建一个固定大小的线程池,包含3个线程
        ExecutorService executorService = Executors.newFixedThreadPool(3);

        // 循环提交任务到线程池
        for (int i = 0; i < 10; i++) {
            executorService.submit(() -> {
                // 为每个线程创建一个较大的数组对象,并存储到ThreadLocal中
                int[] largeArray = new int[10000];
                threadLocal.set(largeArray);
				
                // 如果没有清理ThreadLocal中的数据,就会造成内存泄露
				//threadLocal.remove();
            });
        }

        // 关闭线程池,等待所有任务完成
        executorService.shutdown();
        executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.DAYS);

        // 此时,由于没有清理ThreadLocal中的数组对象,并且线程池中的线程会被复用
        // 这些大数组对象会一直占用内存,随着任务不断执行,内存占用会越来越大,导致内存泄漏
    }
}

简单来说,就是线程池的复用会导致Thread一直存在引用关系,因此线程不会销毁。而线程不销毁,因此一直保持着对ThreadLocal的引用。所以GC就不会释放这个对象,随着线程被不断复用,ThreadLocal慢慢累积,从而导致内存泄露。

在C#的世界中,ThreadLocal并不会造成内存泄露,主要仰仗Disposable模式,让我们在源码中一探究竟。

public class ThreadLocal<T> : IDisposable
{
	//初始化默认值的方法
	private Func<T>? _valueFactory;
	
	//存储着每个线程的不同值
	[ThreadStatic]
	private static LinkedSlotVolatile[]? ts_slotArray;
	
	//当前线程所对应的索引,取值类似ts_slotArray[s_idManager]
	private static readonly IdManager s_idManager = new IdManager();
	
	//线程结束后清理:该对象的析构函数会枚举ts_slotArray,并把结束线程的元素移出链表。达到没有引用的目的
	[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);//创建一个双向链表,用于记录所有写入过ts_slotArray的元素。
		}
	}
	//Dispose 会自动将元素设为null
	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;
		}
	}
}

posted on 2024-11-22 14:02  叫我安不理  阅读(881)  评论(3编辑  收藏  举报

导航