在写程序的过程中,我们可能会需要对某些功能实现异步操作,比如记录调用日志等。
提到异步,我们最容易想到的就是多线程:我们可以启动另外一个线程,把一部分工作交给另外一个线程去执行,而当前线程继续去做一些更加急迫的事情。这里的“把一部分工作交给另外一个线程取执行”,是通过将要执行的函数的函数入口地址告诉另外一个线程来实现的,当新的线程有了函数的入口地址,就可以调用该函数。
我们先来看一下怎样使用C#中的Thread类来实现异步。
- 使用Thread类异步执行一个方法
在C#中,Thread类是常用的用来启动线程的类:









实际上,这里创建的ThreadStart对象,封装的就是方法“myStartingMethod”的入口地址。C#中通过Delegate对象,可以方便的封装函数入口地址。
而Delegate,实际上是用来描述函数定义的,比如上面提到的ThreadStart委托,他的声明如下:

这句话声明了一个叫做ThreadStart的委托类型,而且该声明表示:ThredStart这个委托类型,只能封装“返回值为void、没有参数”的函数的入口地址。如果我们给ThreadStart类的构造函数传递的方法不符合,则会出错:











实际上,我们在使用多线程时,要异步执行的函数往往会有一些参数。比如记录日志时,我们需要告诉另外一个线程日志的信息。
因此,Thread类除了接受ThreadStart委托,还接受另外一个带参数的委托类型ParameterizedThreadStart:











ParameterizedThreadStart 委托可以用来封装返回值为void、具有一个object类型参数的函数。这样,我们就可以往另外一个函数中传递参数了——只不过,如果要传递多个参数,我们必须将参数封装一下,弄到一个object对象中去。比如下面的例子中,本来我们需要传递两个整数的,但为了符合ParameterizedThreadStart的声明,我们需要改造一下函数:






















ParameterizedThreadStart委托必须与Thread.Start(Object) 方法一起使用——委托只是用来传递函数入口,但函数的参数是通过Thread.Start方法传递的。
另外需要注意的,从这里我们可以看到,这样的使用方法并不是类型安全的,我们无法保证myStartingMethod方法的参数threadData永远都是MyStartingMethodParameterWarpper 类型,因此我们还需要加上判断;另外这样实际上也加大了程序间的沟通成本:如果有人需要异步执行myStartingMethod方法,那么他就必须知道其参数的实际类型并保证参数传递正确,而这块编译器已经无法通过编译错误的方式通知你了。
至此,我们只解决了传递参数的问题。
Thread类无法执行一个包含有返回值的函数。我们知道“int a = Math.Sum(1, 2)”是将Sum函数的返回结果复制给了变量a,但如果用了多线程,那么这个线程不知道将这个返回结果复制到哪里,因此接受这样的一个函数是没有意义的。于是产生了另外一个重要的问题:如果我想要知道一步执行的结果,也就是如果我的线程函数具有返回值,我应该怎样做呢?
解决的方法有很多种。
顺着刚才解决传递参数的思路,我们可能会想到:如果Thread类接受一个包含有一个object类型的输入参数和一个object类型的输出参数,不就可以了么?嗯,这个思路听起来不错。不过很不幸的是,MS并没有提供这个接口。
如此看来,我们是没法直接得到异步函数的执行结果了。
不过没关系,我们可以间接的得到——我们可以在线程函数内,把函数的返回值保存在一个约定好的地方,然后在主线程到那里去取就可以了!
因此,考虑到object对象是引用类型,我们可以返回值直接放在线程函数的参数中:



























回顾上面的封装函数参数、封装函数返回值的做法,我们的思路实际上是“将线程函数的参数、返回值封装在对象中”。而刚刚我们也提到了,ParameterizedThreadStart 委托和 Thread.Start(Object) 方法重载使得将数据传递给线程过程变得简单,但由于可以将任何对象传递给 Thread.Start(Object),因此这种方法并不是类型安全的。将数据传递给线程过程的一个更可靠的方法是将线程过程和数据字段都放入辅助对象:


























刚才在我们获取函数返回值时,都使用了一个While循环来等待线程函数执行完毕。但这种方式可能是不好的——假设我们启动一个线程,这个线程尝试去获得一个打开的数据库链接,而主程序需要在获得该连接后马上得到通知。看下面这段:






























































上面的代码,虽然我们在每次执行一个代码段后就判断线程有没有执行完,但实际上仍然不是及时的——仍然无法保证在函数执行完后就第一时间就启动了函数DoSomething,因为每个代码段执行过程中也许消耗了很长时间,而在这段时间内另一个线程早就执行完了。
这样的主动轮询的方法,实在是比较累,而且及时性也不好。
那么,Thread类接受了一个函数入口地址,线程在启动后就会去执行这个函数。那么,假设我们给线程多传递一个函数入口地址,叫线程在执行完线程函数之后就马上执行这个函数,那我们岂不是。。。就能第一时间得知函数已经执行完了?想法很好。看我们来改造:



































注意线程方法MyStartingMethod的最后一句,这里实际上就是执行了委托对象OnMyStartingMethodCompleted中所封装的那个函数入。当然为此我们专门定义了一个表示方法MyStartingMethod已经执行完毕的一个委托MyStartingMethodCompleteCallback,他没有返回值,只有一个参数就是方法MyStartingMethod所属的对象。
当然,这里的通知,我们也可以使用Event来实现。不过event的实现方法偶就不写了,,今天写的好累。剩下的事情,就留给大家自己搞吧。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· Ollama——大语言模型本地部署的极速利器
· [AI/GPT/综述] AI Agent的设计模式综述