回调 委托
本篇文章主要讲解委托的底层原理,但在此之前,我们必须先理清委托究竟是什么——其不过是 callback 在 C# 中的具象化体现。因此,在真正进入委托的底层实现之前,我们需要先回到 callback 本身:它是如何工作的、为什么需要它、以及它在异步与解耦中的角色。理解了这些,再看委托的内部结构、多播机制和调用流程,就会变得顺理成章。
理解callback
【callback是什么?】callback是一种思想,其核心原理是:你定义一个函数,将其作为参数交给某个框架、库,对方将其保存起来,等到某个条件满足时,比如资源加载完成、状态发生变化时,再执行该函数。
【为什么要有callback?】而之所以要有callback,其根本动机是为了实现控制权的反转,callback函数就像是一个钩子函数一样,你将你自己的逻辑“挂到”一个更大的系统中,将逻辑的调用权从你手里,转换到了框架手里,由框架在它自己的某个生命周期节点调用你的函数。从callback这个名字中也能感受到这层意思:不是我主动call你,而是你在合适的时机call back我
这样做的意义是可以让模块间解耦,这也是callback的首要目标。 比如一个资源下载系统,用户需要在下载完成后执行一些逻辑,比如将下载的资源显示出来,或是打印日志等,这些逻辑不应该由资源下载系统决定,资源下载系统也不可能知道你用户到底想干什么,此时就需要callback——由用户将“资源下载完成后的逻辑”,通过参数的方式传递给资源下载函数,此时该函数的调用权就到了资源下载系统手中,等到资源下载完成时则会调用该逻辑
//资源下载系统:
void Download(Action onComplete)
{
// 下载逻辑
//.....
//下载完成 调用用户传入的逻辑
onComplete?.Invoke();
}
//用户:
Download(() => {
Debug.Log("UI更新");
});
【什么场景下需要callback呢?】在callback核心原理中提到,函数逻辑“等到某个条件满足时”再被调用。这里”等“的主体是帮你做事的系统、框架等,而不是要你去等,你在将callback函数交给系统、框架后就继续向后执行了,称其为异步,异步是callback最经典的应用场景。比如网络请求、IO操作、事件系统等,其都不能立马返回结果,只能在未来条件满足时通知你,此时callback在时序上进行解耦
相对地,如果“等”的主体是你,即同步,那么没必要使用callback(实际上,在同步下使用callback可以理解为”策略注入“,这里说没必要指的是没必要为了在时序上解耦而使用callback,因为不存在耦合)。如果条件都满足了,比如资源都加载完成了,那为什么不直接将资源作为返回值直接给你呢?直接根据结果进行调用不就好了?但这种方法闲置了CPU资源,在你等待期间,主线程一直被阻塞,难以处理高并发需求
如果我们从栈与函数调用的角度来看:同步下,函数的后续执行可以依赖于栈机制自动恢复,继续调用你提供的函数;但是在异步下,函数在获取到结果返回前就已经结束了,调用栈被销毁,因此必须将”后续的逻辑“从栈中“剥离”出来,以callback的方式保存,以在未来由完成方调用。
【callback的语言级别的实现?】C中的函数指针、C++中的lambda、function、C#中的委托等等,本质上都是在实现callback,只不过其具有的能力不同
C的函数指针,本质上就是告诉框架、系统一段函数地址,条件满足后你条过去执行就可以,这是最基本、最本质的实现;而C++中的lambda/C#的委托则通过闭包来支持上下文的捕获,此时的callback就不只是函数地址了,还有一份上下文数据;而C++中的function则是将多种callback的形式做了一个统一,其可以支持装载函数对象、lambda、bind等
这里提到了闭包,实际上闭包的好处不止是提供了上下文数据,其在内存管理上同样起到了十分重要的作用。在没有GC的语言下,比如C++,其通过RAII的思想,通过作用域来管理资源的生命周期。而在异步编程下,作用域起不到较好的资源管理作用,容易出现资源提前释放或泄露的问题,所以可以让闭包持有资源的所有权,将栈上的资源提升到堆,让资源脱离原有的作用域并延续其生命周期,再利用闭包的作用域机制将资源安全释放
【callback的缺点在哪?】callback最严重的问题在于异步链条过长,callback层层嵌套,导致代码可读性差。而为了解决这个问题,从而出现了async/await这样的语法糖,允许我们以同步的方式写异步,通过底层的状态机对象以及回调注册机制,自动帮我们实现callback的效果
getUser(id, function(user) {
getOrders(user, function(orders) {
getDetails(orders, function(details) {
console.log(details);
});
});
});
理解委托 事件
委托是一个面向对象的,类型安全的函数指针。 具体来说,其相比于C/C++中的函数指针有三个不一样的地方:
面向对象。当我们通过delegate void Demo()声明一个委托时,本质上就是定义了一个叫做Demo的类型;而当我们创建委托实例时,比如Demo ptr = this.DoSomething时,本质上就是实例化该委托类并将其中包含的函数指针、对象指针赋值,和C/C++中获取函数指针类似;当我们只有委托时,比如ptr.Invoke(),本质上就是C/C++中通过函数指针对函数进行调用。所以说,委托是一个面向对象的函数指针,其将各种对函数指针的操作都封装在了类中
类型安全。委托作为一个托管类,其同样也要遵守一般类的规则,哪怕返回值、参数列表相同,但是名称不同,在CLR看来就是两个相互独立的类,不允许相互转型
多播。这是委托相较于函数指针独有的特点,允许为一个委托对象绑定多个方法,底层通过数组承载,在执行委托时,会依次执行这些方法
事件和委托?
事件是对委托的一次封装。 委托本质上是一个类,所以委托字段就和其他字段一样,最好被封装为属性,不要直接暴露给外界。而对于委托类型的字段来说,其就是被封装为事件——属性通过get/set方法提供对字段的访问,而事件则提供add/remove方法,提供对委托的访问
委托底层原理
当我们定义一个委托类型时,编译器会为我们生成一个对应的类,类中主要会维护三个指针:一个是对象指针,指向this(当是成员函数时)或者委托实例自己(当是静态函数时),另外两个是方法指针,分别指向成员函数和静态函数。
public abstract partial class Delegate : ICloneable, ISerializable
{
internal object? _target; //如果注册的是实例方法,则是this指针,如果是静态则是delegate实例自己。
internal object? _methodBase; //缓存
internal IntPtr _methodPtr;//实例方法的入口,看到IntPtr关键字就知道要与非托管堆交互,必然就是函数指针了,
internal IntPtr _methodPtrAux;//静态方法的入口
}
public abstract class MulticastDelegate : Delegate
{
//多播委托的底层基石
private object? _invocationList;
private nint _invocationCount;
//实例委托调用此方法
private void CtorClosed(object target, IntPtr methodPtr)
{
if (target == null)
ThrowNullThisInDelegateToInstance();
this._target = target;
this._methodPtr = methodPtr;//函数指针被指向_methodPtrAux
}
//静态委托调用此方法
private void CtorOpened(object target, IntPtr methodPtr, IntPtr shuffleThunk)
{
this._target = this;//上面说到,_target的注释不对的判断就在此
this._methodPtr = shuffleThunk;//与实例委托不同,这里被指向一个桩函数
this._methodPtrAux = methodPtr;//函数指针被指向_methodPtrAux
}
}
在为委托赋值时,底层会根据该方法对应的MethodDesc运行时对象中的信息(但是根本的信息来源是MethodDef元数据),判断该对象是一个静态方法还是一个成员方法(实际上会更复杂,这里简化),并调用对应的函数:
如果是静态方法:则调用CtorOpened,让对象指针指向委托自己,并为对应的函数指针赋值
如果是成员方法:则调用CtorClosed,让对象指针指向实例对象,同样为对应的函数指针赋值
此时,三个指针都已经初始化完成,之后就可以通过使用委托时,就可以在底层通过对象指针调用到对应的成员方法了。不过,成员方法的调用和静态方法的调用并不相同:前者需要传入this指针,而后者不需要。所以为了统一调用模型,会在静态方法的调用上额外做一层适配
//实例方法:
delegate.Invoke()
→ _methodPtr(target, args...)
//静态方法:
delegate.Invoke()
→ _methodPtr (thunk)
→ 调用 _methodPtrAux (真正静态函数)
_methodPtr指向的适配函数:
void ShuffleThunk(object target, int x)
{
// 忽略 target(因为静态方法不需要 this)
// 调用 _methodPtrAux 指向的静态函数
RealStaticMethod(x);
}
多播委托的实现


多播委托的实现依赖于MulticastDelegate中的两个字段:
_invocationList:其是一个object[],专门用于存储委托调用列表,其使用方式和扩容策略类似于List
_invocationCount:代表了列表中委托的数量
+=时所依赖的关键函数CombineImpl:
在调用 += 时,其负责将当前delegate和其他delegate组合为一个新的delegate。在要放入新的delegate时,会尝试将delegate放入到原先的object[]中。但如果要放入的位置上已经有delegate且不相同,或者说obejct[]中根本没地方放,则会通过二倍扩容的方式,新分配一个object[]并拷贝数据(对于引用类型来说是浅拷贝)、追加新delegate。在放入完成后,会分配一个新的delegate对象并返回,其引用的是一个新的object[],并更新_invocationCount
注意,是返回一个新的delegate对象,而不会复用同一个delegate对象(为了优化,会尝试复用同一个object[])。因为委托属于“不可变对象”,有点像值语义对象,类似于C#的string类型,以保证行为可预测,防止出现意外,同时多线程访问安全。比如以下场景:
void Register(Action handler) { // 如果委托可变 handler += AnotherMethod; // 修改了传入的委托! // 外部调用方会惊讶地发现自己的委托被改变了 }
之所以有可能要放入的位置上已经有了delegate,是因为不同的delegate对象可能共享同一个object[],只不过通过_invocationCount来代表其所拥有的delegate,以在遍历调用时确定实际范围,比如以下场景:
Action a = A + B + C;
Action b = a;
a += D;
//object[]可能是这样的:
[A, B, C, D, null, null]
//a和b共享同一个object[](注意,a和b只是共享object[],而不共享Action对象),但是 a._invocationCount = 4,b._invocationCount = 3
b: count=3 → A, B, C
a: count=4 → A, B, C, D
CombineImpl具体逻辑:runtime/src/coreclr/System.Private.CoreLib/src/System/MulticastDelegate.CoreCLR.cs at main · dotnet/runtime
protected sealed override Delegate CombineImpl(Delegate? follow)
{
// 1. 空值检查:如果 follow 是 null,直接返回当前委托本身
if (follow is null)
return this;
// 2. 类型安全检查:确保要合并的两个委托的类型签名完全一致
if (!InternalEqualTypes(this, follow))
throw new ArgumentException(SR.Arg_DlgtTypeMis);
// 3. 转换为多播委托类型以便访问内部字段
MulticastDelegate dFollow = (MulticastDelegate)follow;
// 4. 分析 follow 委托的结构
object[]? resultList; // 结果列表,将存储合并后的所有委托
int followCount = 1; // follow 中的委托数量,默认是单播委托
object[]? followList = dFollow._invocationList as object[]; // 获取 follow 的调用列表
// 如果 followList 不为 null,说明 follow 是一个多播委托
if (followList != null)
followCount = (int)dFollow._invocationCount; // 从字段中获取实际的委托数量
int resultCount; // 合并后的总委托数量
// 5. 情况1:当前委托是单播委托(_invocationList 不是 object[])
if (_invocationList is not object[] invocationList)
{
// 5.1 计算合并后的总数量 = 当前委托(1) + follow 中的委托数量
resultCount = 1 + followCount;
// 5.2 创建结果数组,长度为总数量
resultList = new object[resultCount];
// 5.3 将当前委托放入数组首位
resultList[0] = this;
// 5.4 将 follow 的委托列表复制到结果数组中
if (followList == null)
{
// 如果 follow 是单播,直接放入第二个位置
resultList[1] = dFollow;
}
else
{
// 如果 follow 是多播,遍历其调用列表
for (int i = 0; i < followCount; i++)
resultList[1 + i] = followList[i];
}
// 5.5 创建新的多播委托(当前是单播,所以 thisIsMultiCastAlready = false)
return NewMulticastDelegate(resultList, resultCount);
}
// 6. 情况2:当前委托已经是多播委托
else
{
// 6.1 获取当前多播委托的委托数量和调用列表
int invocationCount = (int)_invocationCount;
resultCount = invocationCount + followCount; // 计算合并后的总数量
resultList = null; // 结果列表初始化为空
// 6.2 尝试复用当前委托的调用列表数组(空间足够时才尝试复用)
if (resultCount <= invocationList.Length)
{
resultList = invocationList; // 复用现有数组
if (followList == null)
{
// 如果 follow 是单播委托,尝试将其设置到数组的可用位置
if (!TrySetSlot(resultList, invocationCount, dFollow))
resultList = null; // 设置失败(如重复委托),放弃复用
}
else
{
// 如果 follow 是多播委托,尝试设置其中的每个委托
for (int i = 0; i < followCount; i++)
{
if (!TrySetSlot(resultList, invocationCount + i, followList[i]))
{
resultList = null; // 任何一个设置失败,就放弃复用
break;
}
}
}
}
// 6.3 如果无法复用原数组(空间不足或TrySetSlot失败),创建新数组
if (resultList == null)
{
// 6.3.1 计算新数组大小:至少是原来的2倍,但必须能容纳所有委托
int allocCount = invocationList.Length;
while (allocCount < resultCount)
allocCount *= 2; // 按2倍扩容,减少后续扩容次数
// 6.3.2 创建新数组
resultList = new object[allocCount];
// 6.3.3 复制当前委托的所有委托到新数组
for (int i = 0; i < invocationCount; i++)
resultList[i] = invocationList[i];
// 6.3.4 复制 follow 的委托到新数组
if (followList == null)
{
resultList[invocationCount] = dFollow;
}
else
{
for (int i = 0; i < followCount; i++)
resultList[invocationCount + i] = followList[i];
}
}
// 6.4 创建新的多播委托(当前已是多播,所以 thisIsMultiCastAlready = true)
return NewMulticastDelegate(resultList, resultCount, true);
}
}
/// <summary>
/// 尝试在指定数组索引处设置委托对象
/// 此方法用于在合并多播委托时,将新的委托添加到调用列表数组的指定位置
/// </summary>
/// <param name="a">调用列表数组,可能包含null或已有的委托引用</param>
/// <param name="index">要设置的数组索引位置</param>
/// <param name="o">要设置的委托对象,必须是MulticastDelegate类型</param>
/// <returns>如果成功设置或委托已存在,则返回true;如果位置已被不同委托占用,则返回false</returns>
private static bool TrySetSlot(object?[] a, int index, object o)
{
// 第一阶段:原子性尝试设置(处理并发场景)
// 1. 检查指定索引位置是否为空
// 2. 如果是空的,则使用Interlocked.CompareExchange原子性地尝试设置委托对象
// 3. CompareExchange的作用:比较a[index]当前是否为null,如果是则用o替换,返回原始值
// 4. 如果原始值为null,说明我们成功设置了该位置,返回true
if (a[index] == null && Interlocked.CompareExchange(ref a[index], o, null) == null)
return true;
// 第二阶段:重复委托检测(处理非并发场景或设置失败后的检查)
// 如果原子设置失败,可能是因为:
// 1. 位置已被其他线程设置了不同的委托
// 2. 位置已经有委托(可能是之前添加过但又被移除了)
// 3. 当前线程之前已经设置过相同的委托
// 检查指定位置是否已经有对象存在
if (a[index] is object ai)
{
// 将待设置的委托和数组中现有的委托都转换为MulticastDelegate
MulticastDelegate d = (MulticastDelegate)o; // 要添加的新委托
MulticastDelegate dd = (MulticastDelegate)ai; // 数组中已存在的委托
// 比较两个委托是否表示相同的方法调用
// 通过比较三个核心字段来确定委托的完全相等性:
// 1. _methodPtr: 方法指针,指向实际执行的方法
// 2. _target: 目标对象,对于实例方法是对象实例,对于静态方法是null
// 3. _methodPtrAux: 辅助方法指针,在多播委托中用于处理调用逻辑
if (dd._methodPtr == d._methodPtr &&
dd._target == d._target &&6
dd._methodPtrAux == d._methodPtrAux)
{
// 如果两个委托完全相等,则视为"成功设置"
// 这发生在:同一个方法被移除后又重新添加,或者多个线程尝试添加相同委托的情况
return true;
}
}
// 设置失败:指定位置已被不同的委托占用
return false;
}
对于多播委托,在Invoke时是如何调用的呢? 依据上面的分析,可知会通过_invocationCount控制遍历范围,仅调用_invocationList的前_invocationCount个元素,从而允许多个delegate共享同一个object[]但保持逻辑隔离,是按内存共享
具体的多播调用逻辑可以在runtime/src/coreclr/vm/comdelegate.cpp at main · dotnet/runtime查看,其是一个非托管方法
总结
至此,我们从 callback 的思想一路走到了委托的底层实现。你会发现,所谓的“高级特性”,往往只是基础思想在语言层面的精巧包装。委托没有创造新的概念,它只是让 callback 变得更安全、更强大,也更面向对象。 看清了这一点,剩下的 API 细节,也就只是顺理成章的延伸了。