.NET 6 高级特性与调试技巧深度解析
1. 调试源生成器
在使用源生成器时,Intellisense 可能需要重启 Visual Studio 才能识别生成的对象。调试源生成器并不像设置断点然后点击运行按钮那么简单,但也不是特别困难。我们可以使用
System.Diagnostics
命名空间中的
Debugger
类以编程方式暂停生成器的执行。以下是在代码生成开始处添加调试语句的示例:
public void Execute(GeneratorExecutionContext context)
{
Debugger.Launch();
}
当我们通过重建使用该生成器的程序再次触发源生成器时,会弹出选择调试器的消息框。选择“New instance of Visual Studio 2022”,VS2022 会启动,加载生成器的源文件,并在
Debugger.Launch
语句处暂停,就像设置了断点一样。从这一点开始,我们就进入了调试模式,可以检查变量、单步执行语句等。
Debugger.Launch
调用可以放在生成器的任何位置,甚至可以放在语法接收器中。
2. .NET 编译器平台概述
.NET 的编译器平台功能强大,它不仅仅是用于编译代码,还是一个完整的检查和代码规范工具。该平台附带了一个 SDK,允许我们编写自己的检查和修复程序,这在团队协作中非常有用,可以确保团队遵循代码风格约定,同时也有助于检测 bug 和反模式。自 .NET 5 以来,该平台还引入了源生成器。使用源生成器,我们可以在编译时生成代码,并将其注入到编译器管道中,就好像是用户编写的代码一样。源生成器非常有用,通常可以替代以前使用反射的场景,例如生成 DTO 类型。
3. 垃圾回收器
编写托管代码的一个重要优势是可以使用垃圾回收器(GC)。垃圾回收器负责管理内存使用,在需要时分配内存,并在不再使用时自动释放内存。这大大有助于防止内存不足问题,但并不能完全消除风险,作为开发者,我们也需要明智地进行内存分配。
3.1 内存结构
内存由栈和堆两部分组成。一种常见的误解是栈用于值类型,堆用于引用类型,这并不完全正确。引用类型总是存储在堆上,而值类型存储在其声明的位置。每次调用方法时,会创建一个帧并将其放在栈上。栈是一系列帧的集合,我们只能访问最顶部的帧。当一个方法执行完毕,其帧会从栈中移除,我们可以继续处理下一个帧。当方法中发生错误时,我们通常会在 Visual Studio 中看到堆栈跟踪信息,这是错误发生时栈上内容的概述。方法中声明的变量通常存储在栈上。以下是一个简单的示例:
public int Sum(int number1, int number2)
{
int result = number1 + number2;
return result;
}
当调用这个方法时,栈的状态会发生相应变化。如果我们实例化一个类并调用其方法,情况会更复杂一些:
Math math = new Math();
int result = math.Sum(5, 6);
public class Math
{
public int Sum(int number1, int number2)
{
int result = number1 + number2;
return result;
}
}
在这个例子中,类实例存储在堆上,栈中包含一个指向该实例的指针。调用类成员
Sum()
会在栈上创建第二个帧,变量也存储在这个帧中。
3.2 堆的分类
在 .NET 中有两个对象堆:大对象堆和小对象堆。小对象堆包含大小小于 85K 的对象,其他对象则存储在大对象堆中。这种划分是为了提高性能,因为较小的对象更容易检查,所以垃圾回收器在小对象堆上的工作速度更快。堆上的对象包含一个地址,可以从栈中指向该对象,因此称为指针。确定对象大小的因素超出了本文的范围,这里不再详细讨论。
3.3 栈的作用
栈用于跟踪每个方法调用的数据。对于每个方法,会创建一个帧并放在栈的顶部。帧可以看作是一个盒子或容器,包含方法创建或封装的所有对象或指向这些对象的指针。方法返回后,其帧会从栈中移除。
3.4 垃圾回收过程
垃圾回收器是 .NET 运行时中的一个软件组件,它会检查堆中不再被引用的已分配对象,如果找到任何这样的对象,就会将它们从堆中移除,以释放内存。这只是垃圾回收器工作的一个方面,其他方面还包括全局引用或 CPU 寄存器,这些被称为 GC Roots。
垃圾回收包括几个阶段:
1.
标记阶段
:GC 会列出所有 GC Roots,然后遍历这些根的所有引用树,标记仍然有引用的对象。
2.
引用更新阶段
:更新指向将被压缩的对象的引用。
3.
内存回收阶段
:回收死亡对象的内存。在此阶段,存活的对象会被移动到一起,以最小化堆的碎片化。这种压缩通常只发生在小对象堆上,因为大对象堆上的大对象移动起来太耗时。不过,在需要时可以手动触发大对象堆的压缩。
垃圾回收器在 .NET 应用程序中自动运行,触发垃圾回收的情况有三种:
-
内存不足
:当物理内存不足时,操作系统可以触发一个事件,.NET 运行时会捕获这个事件并触发垃圾回收以恢复内存。
-
堆阈值超过
:每个 .NET 进程都有一个托管堆,每个托管堆都有一个可接受的阈值。这个阈值是动态的,在进程运行时可能会改变。一旦超过阈值,就会触发垃圾回收。
-
手动调用
GC.Collect()
:
System.GC
是垃圾回收器的静态包装器,其
Collect
方法可以触发垃圾回收。我们可以手动调用它进行测试或在非常特定的场景中使用,但通常不需要担心这个问题。
4. 线程池
在 .NET 中,线程池是一组可以用来安排工作的后台线程。根据应用程序运行的系统,运行时会创建一组后台工作线程。如果我们请求的线程池线程数量超过可用数量,系统会创建额外的后台线程并保持它们的存活状态以供将来使用。由于线程池线程是后台线程,它们不能使进程保持运行。一旦所有前台线程退出,应用程序将关闭,所有后台线程也将终止。
自 .NET 4 以来,线程池比手动创建线程更受青睐,主要原因是性能。线程池线程已经存在,只需要给它们分配工作单元,而手动创建线程需要花费更多的资源。使用任务并行库(TPL)可以很容易地使用线程池。以下是一个示例:
var strings = new List<string>();
for (int i = 0; i < 1000; i++)
{
strings.Add($"Item {i}");
}
Parallel.ForEach(strings, _ =>
{
Console.WriteLine(_);
Thread.Sleep(1000);
});
在这个示例中,我们有一个包含 1000 个字符串的列表,使用
Parallel.ForEach
可以并行遍历这个列表,每个列表项的工作将安排在线程池线程上执行。在 Visual Studio 的线程窗口中可以可视化这个过程。
并行执行
foreach
循环意味着结果的顺序可能是不可预测的。另一种并行遍历集合的方法是使用
AsParallel
扩展方法。
AsParallel
是 LINQ 库中的一个方法,它返回一个
ParallelQuery
对象。它本身不会进行并行化,我们需要对返回的
ParallelQuery
对象执行 LINQ 查询。以下是使用该方法的示例:
var strings = new List<string>();
for (int i = 0; i < 1000; i++)
{
strings.Add($"Item {i}");
}
foreach (string item in strings.AsParallel().Select(_ => _))
{
Console.WriteLine(item);
Thread.Sleep(1000);
}
使用
Parallel.ForEach
和
AsParallel
没有太大区别,只是使用方式不同,但结果相似。
静态的
ThreadPool
类可以告诉我们线程池中同时可以有多少个工作线程。以下是获取线程池信息的示例:
ThreadPool.GetMaxThreads(out int workerthreads, out int completionports);
Console.WriteLine($"Max number of threads in the threadpool: {workerthreads}");
Console.WriteLine($"Max number of completion ports in the threadpool: {completionports }");
线程池由两种类型的线程组成:工作线程和完成端口。完成端口用于处理异步 I/O 请求。使用完成端口处理 I/O 请求比为 I/O 工作创建自己的线程更高效。不过,需要使用完成端口的情况并不多,这些类型的线程通常在 .NET 库本身处理 I/O 中断和请求的部分使用。
大多数情况下,使用线程池的工作线程更好,但在以下几种情况下,创建自己的线程可能会有用:
-
更改线程优先级
:线程池线程不能更改优先级,需要创建新线程。
-
创建前台线程
:线程池线程是后台线程,如果需要创建前台线程以保持进程在主线程退出时仍然打开,就需要创建自己的线程。
-
长时间运行的任务
:长时间运行的任务可以安排在线程池线程上,但线程数量有限,系统在达到一定数量后会创建新线程,这可能会影响性能。如果安排了很多长时间运行的任务,可能会耗尽线程池线程,这时可以考虑创建自己的线程。但这要根据具体情况谨慎处理。
5. .NET 6 中的异步编程
Async/Await
在 .NET 中已经存在一段时间了,大多数 .NET 开发者应该熟悉如何使用它。.NET 6 为
await/async
模式添加了一些新特性,在深入了解新内容之前,我们先回顾一下基础知识。
5.1
Await/Async
基础
以下是一个使用
HttpClient
异步获取网页内容的简单示例:
public async Task<string> FetchData()
{
var client = new HttpClient();
HttpResponseMessage response = await client.GetAsync("https://www.apress.com").ConfigureAwait(false);
string html = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
return html;
}
在这个示例中,我们注意到几个不同的地方。首先,我们将方法标记为
async
,只有在使用
async
关键字修饰的方法、lambda 表达式或匿名方法中才能使用
await
关键字。
Await/async
在同步函数、不安全上下文或
lock
语句块中不起作用。方法的返回类型是
Task<string>
,
Task
是异步方法常用的返回类型之一,它来自 .NET 中的任务并行库,表示一个工作单元及其状态。当我们等待一个
Task
时,会等待其状态变为完成后再继续执行方法的其余部分。例如,当我们等待
GetAsync
时,方法执行会在那里停止,将方法的其余部分安排为后续操作。一旦 HTTP 调用完成,结果会传递给后续操作,方法的其余部分继续执行。如果使用反编译器(如 ILSpy)反编译这段代码,我们可以清楚地看到框架是如何在我们的代码中引入状态机来跟踪
Task
的状态的。
使用
await/async
会生成很多代码,因此建议谨慎使用,异步编程并不总是比同步开发更好或更快。
当等待一个操作时生成的
Task
对象会捕获调用它的上下文。当等待一个异步方法时,如果不指定
ConfigureAwait(false)
,方法会在线程池上执行工作,并在完成后切换回调用者的上下文。这正是我们请求网页结果并立即将数据放入绑定属性时所需要的行为,因为绑定操作发生在 UI 线程上。但当我们在库或服务类中执行代码时,这不是我们想要的,这时我们会使用
ConfigureAwait(false)
。
在 ASP.NET 中,自 .NET Core 3.1 以来,我们不需要调用
ConfigureAwait(false)
,因为没有要返回的同步上下文。而 Blazor 则有同步上下文。以下是一个在 WinForms 应用程序中使用
ConfigureAwait(false)
的示例:
private async Task FetchData()
{
var service = new ResourcesService();
var result = await service.FetchAllResources();
//textblock is bound against Json
JsonTextbox.Text = result;
}
public class ResourcesService
{
public async Task<string> FetchAllResources()
{
var client = RestClient.GetClientInstance();
var result = await client.GetAsync("/api/data").ConfigureAwait(false);
string json = await result.Content.ReadAsStringAsync().ConfigureAwait(false);
return json;
}
}
在
FetchAllResources
方法中,两次调用都使用了
ConfigureAwait(false)
,因为我们不需要切换回调用者的上下文。通过不返回调用者上下文,我们避免了两次上下文切换。而
FetchData
方法没有使用
ConfigureAwait(false)
,因为它需要返回调用者上下文,这里的调用者上下文是 UI 线程,返回值要设置的属性会触发更改通知,所以我们需要在 UI 线程上操作。
5.2 取消操作
在异步操作中,我们经常使用
CancellationToken
来取消长时间运行的任务,这些令牌在基类库中也经常使用。不过,取消任务的情况并不常见,因此能够重用生成
CancellationToken
的
CancellationTokenSource
对象会很有趣。在 .NET 6 之前,我们不能安全地这样做,因为我们不能确定是否还有任务引用这个令牌。在 .NET 6 中,
CancellationTokenSource
扩展了
TryReset
方法。以下是使用
TryReset
方法的示例:
CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();
private void CancelButton_OnClick(object sender, EventArgs args)
{
_cancellationTokenSource.Cancel();
}
public async Task DoWork()
{
if (!_cancellationTokenSource.TryReset())
{
_cancellationTokenSource = new CancellationTokenSource();
}
Task<string> data = FetchData(_cancellationTokenSource.Token);
}
public async Task<string> FetchData(CancellationToken token)
{
token.ThrowIfCancellationRequested();
var client = new HttpClient();
HttpResponseMessage response = await client.GetAsync("https://www.apress.com", token).ConfigureAwait(false);
string html = await response.Content.ReadAsStringAsync(token).ConfigureAwait(false);
return html;
}
一旦令牌被实际取消,就不能再回收,
TryReset
方法会返回
false
。这个示例来自一个 WinForms 应用程序,我们可以加载数据并使用取消按钮取消操作。调用
DoWork
方法时,我们尝试重置
CancellationTokenSource
,如果失败,就实例化一个新的。
综上所述,.NET 6 在调试、内存管理、线程池和异步编程等方面都有很多值得深入研究的特性,开发者可以根据具体需求合理利用这些特性来提高开发效率和应用程序性能。
.NET 6 高级特性与调试技巧深度解析
6. 调试源生成器与异步编程的关联与注意事项
在实际开发中,调试源生成器和异步编程往往会同时出现,它们之间存在着一些关联和需要注意的地方。
当在源生成器中使用异步操作时,要特别注意上下文的切换。由于源生成器是在编译时运行的,它的执行环境和运行时的异步操作有所不同。如果在源生成器中使用
await
关键字,需要确保正确处理上下文,避免出现意外的错误。例如,在源生成器中调用异步方法时,同样要考虑是否使用
ConfigureAwait(false)
来避免不必要的上下文切换。
另外,调试源生成器时,如果其中包含异步代码,可能会增加调试的复杂度。因为异步操作的执行顺序可能不是线性的,这就需要开发者更加仔细地设置断点和观察变量的状态。可以在关键的异步操作前后设置断点,观察异步操作的执行结果和状态变化。
7. 线程池与异步编程的协同工作
线程池和异步编程在 .NET 6 中经常协同工作,它们相互配合可以提高应用程序的性能和响应能力。
在异步编程中,
Task
对象通常会利用线程池来执行任务。当我们使用
await
关键字等待一个
Task
时,当前线程可能会被释放回线程池,以便执行其他任务。例如,在前面的
FetchData
方法中,当调用
client.GetAsync
时,当前线程会被释放,直到异步操作完成后再继续执行后续代码。
线程池的大小和性能也会影响异步编程的效果。如果线程池中的线程数量不足,可能会导致异步任务排队等待执行,从而影响应用程序的响应速度。因此,在设计应用程序时,需要根据实际情况合理调整线程池的大小。可以使用
ThreadPool.SetMinThreads
和
ThreadPool.SetMaxThreads
方法来设置线程池的最小和最大线程数。
以下是一个设置线程池大小的示例代码:
ThreadPool.SetMinThreads(10, 10);
ThreadPool.SetMaxThreads(100, 100);
在这个示例中,我们将线程池的最小线程数和最大线程数分别设置为 10 和 100。
8. 垃圾回收与线程池、异步编程的相互影响
垃圾回收、线程池和异步编程之间存在着相互影响的关系。
在异步编程中,由于会创建大量的
Task
对象和其他临时对象,这些对象会占用堆内存。如果垃圾回收不及时,可能会导致堆内存占用过高,从而影响应用程序的性能。因此,合理使用
ConfigureAwait(false)
可以减少不必要的对象创建,降低垃圾回收的压力。
线程池中的线程也会影响垃圾回收的效率。如果线程池中的线程长时间占用资源,可能会导致垃圾回收器无法及时回收不再使用的对象。例如,长时间运行的任务如果占用了大量的线程池线程,会使垃圾回收器的工作受到阻碍。因此,对于长时间运行的任务,建议创建自己的线程,避免影响线程池的正常工作和垃圾回收的效率。
9. 性能优化建议
为了提高 .NET 6 应用程序的性能,我们可以从调试源生成器、垃圾回收、线程池和异步编程等多个方面进行优化。
9.1 调试源生成器优化
- 减少不必要的代码生成 :在源生成器中,尽量避免生成过多的代码,只生成必要的代码。可以通过条件判断等方式,根据不同的情况生成不同的代码,减少代码的冗余。
- 优化代码生成逻辑 :对源生成器的代码生成逻辑进行优化,提高生成代码的效率。例如,使用缓存机制来避免重复的计算和生成。
9.2 垃圾回收优化
- 合理分配内存 :在编写代码时,要合理分配内存,避免创建过多的临时对象。可以使用对象池等技术来复用对象,减少垃圾回收的压力。
- 控制垃圾回收频率 :根据应用程序的特点,合理控制垃圾回收的频率。可以通过调整堆的阈值等方式,避免频繁的垃圾回收。
9.3 线程池优化
- 合理设置线程池大小 :根据应用程序的负载和性能需求,合理设置线程池的大小。避免线程池中的线程数量过多或过少,影响应用程序的性能。
- 避免长时间占用线程池线程 :对于长时间运行的任务,尽量创建自己的线程,避免长时间占用线程池线程,影响其他任务的执行。
9.4 异步编程优化
-
谨慎使用
await和async:由于使用await和async会生成大量的代码,因此要谨慎使用。只在确实需要异步操作的地方使用,避免不必要的异步化。 -
合理使用
ConfigureAwait(false):在不需要返回调用者上下文的情况下,使用ConfigureAwait(false)来避免不必要的上下文切换,提高性能。
10. 总结与展望
通过对 .NET 6 中调试源生成器、垃圾回收器、线程池和异步编程等高级特性的深入分析,我们可以看到 .NET 6 为开发者提供了强大而灵活的工具和功能。
调试源生成器让我们能够更方便地调试编译时生成的代码,提高开发效率和代码质量。垃圾回收器帮助我们管理内存,减少内存泄漏的风险。线程池和异步编程则让我们能够充分利用多核处理器的性能,提高应用程序的响应能力和吞吐量。
在未来的开发中,随着 .NET 技术的不断发展,我们可以期待更多的优化和改进。例如,可能会有更智能的垃圾回收算法,能够更好地适应不同的应用场景;线程池的管理也可能会更加智能化,根据应用程序的实时负载自动调整线程数量。同时,异步编程模式也可能会进一步简化和优化,让开发者能够更轻松地编写高效的异步代码。
开发者在使用 .NET 6 时,要充分理解这些高级特性的原理和使用方法,根据具体的应用场景合理选择和使用,以达到最佳的开发效果和性能表现。
以下是一个简单的 mermaid 流程图,展示了异步编程中
await
操作的基本流程:
graph TD;
A[开始异步操作] --> B[释放当前线程到线程池];
B --> C{异步操作完成?};
C -- 否 --> B;
C -- 是 --> D[继续执行后续代码];
在这个流程图中,当开始异步操作时,当前线程会被释放到线程池,等待异步操作完成。如果异步操作未完成,则继续等待;如果完成,则继续执行后续代码。
通过以上的分析和总结,希望开发者能够更好地掌握 .NET 6 的高级特性,在实际开发中发挥出它们的最大优势。
超级会员免费看
1784

被折叠的 条评论
为什么被折叠?



