改善C#性能的最佳实践

大家好。“ C#开发者”课程开始的前夕,我们准备了另一本有用的材料的翻译享受阅读。




自从我最近列出了Criteo的C#最佳实践列表以来,我认为最好将其公开共享。本文的目的是提供一个不完整的代码模板列表,应避免使用这些代码模板,这可能是因为它们有问题,或者是因为它们工作不佳。该列表似乎有点随机,因为它是脱离上下文的,但是它的所有元素有时都在我们的代码中找到,并在生产中引起了问题。我希望这将是一个很好的预防方法,并防止您将来犯错。

还要注意,Criteo Web服务依赖于高性能代码,因此需要避免效率低下的代码。在大多数应用程序中,替换其中一些模板不会有明显的明显差异。

最后但并非最不重要的一点ConfigureAwait是,许多文章已经讨论了一些要点(例如),因此我将不对其进行详细介绍。目的是形成您需要注意的要点的紧凑列表,而不是对每个要点进行详细的技术计算。

同步等待异步代码


永远不要期望同步完成的任务。这适用于但不限于:Task.Wait, Task.Result, Task.GetAwaiter().GetResult(), Task.WaitAny, Task.WaitAll

概括地说:两个池线程之间的任何同步关系都可能导致池耗尽。本文描述了这种现象的原因

配置等待


如果可以从同步上下文中调用您的代码,则将其ConfigureAwait(false)用于每个await调用。

请注意,这ConfigureAwait 仅在使用关键字时有用await

例如,以下代码是没有意义的:

//  ConfigureAwait        
var result = ProcessAsync().ConfigureAwait(false).GetAwaiter().GetResult();

异步无效


永远不要使用async voidasync void方法中引发的异常传播到同步上下文,通常会导致整个应用程序崩溃。

如果您无法在方法中返回任务(例如,因为您正在实现接口),则将异步代码移至另一个方法并调用它:

interface IInterface
{
    void DoSomething();
}

class Implementation : IInterface
{
    public void DoSomething()
    {
        //      ,
        //      
        _ = DoSomethingAsync();
    }

    private async Task DoSomethingAsync()
    {
        await Task.Delay(100);
    }
}

尽可能避免异步


出于习惯或由于肌肉记忆,您可以编写以下内容:

public async Task CallAsync()
{
    var client = new Client();
    return await client.GetAsync();
}

尽管代码在语义上是正确的,但是async此处不需要使用关键字,并且在高负载的环境中可能会导致大量开销。尽量避免这种情况:

public Task CallAsync()
{
    var client = new Client();
    return _client.GetAsync();
}

但是,请记住,当代码包装在块中时(例如 try/catchusing,您将无法求助于此优化

public async Task Correct()
{
    using (var client = new Client())
    {
        return await client.GetAsync();
    }
}

public Task Incorrect()
{
    using (var client = new Client())
    {
        return client.GetAsync();
    }
}

在错误的版本(Incorrect())中,客户端可能会在GetAsync调用完成之前被删除,因为using块中的任务不会等待。

区域比较


如果您没有理由使用区域比较,请始终使用顺序比较尽管由于内部优化,这对于en-US数据表示形式并没有多大影响,但对于其他区域的表示形式而言,此比较要慢一个数量级(在Linux上,最多两个数量级!)。由于字符串比较是大多数应用程序中的常见操作,因此开销会大大增加。

并发<>


切勿在ConcurrentBag<T>没有基准测试的情况下使用。此集合专为非常特定的用例而设计(大多数情况下,排队的线程将其从队列中排除),如果将其用于其他目的,则会遇到严重的性能问题。如果你需要一个线程安全的集合,喜欢ConcurrentQueue <牛逼>

ReaderWriterLock / ReaderWriterLockSlim <T >
切勿在没有基准测试的情况下使用ReaderWriterLock<T>/ReaderWriterLockSlim<T>尽管在与读者和作家合作时使用这种专门的同步原语可能很诱人,但它的成本比简单Monitor(与关键字一起使用lock要高得多如果同时执行关键部分的读取器的数量不是很大,那么并发将不足以吸收增加的开销,并且代码将变得更糟。

首选Lambda函数而不是方法组


考虑以下代码:

public IEnumerable<int> GetItems()
{
    return _list.Where(i => Filter(i));
}
private static bool Filter(int element)
{
    return i % 2 == 0;
}

Resharper建议在不使用lambda函数的情况下重写代码,这看起来会更简洁一些:

public IEnumerable<int> GetItems()
{
    return _list.Where(Filter);
}
private static bool Filter(int element)
{
    return i % 2 == 0;
}

不幸的是,这导致为每个调用分配动态内存。实际上,该调用编译为:

public IEnumerable<int> GetItems()
{
    return _list.Where(new Predicate<int>(Filter));
}
private static bool Filter(int element)
{
    return i % 2 == 0;
}

如果在重载部分中调用代码,则可能会对性能产生重大影响。

使用lambda函数可启动编译器优化,该优化可将委托缓存在静态字段中,从而避免分配。这仅在Filter静态时有效。如果没有,您可以自己缓存该委托:

private Predicate<int> _filter;

public Constructor()
{
    _filter = new Predicate<int>(Filter);
}

public IEnumerable<int> GetItems()
{
    return _list.Where(_filter);
}

private bool Filter(int element)
{
    return i % 2 == 0;
}

将枚举转换为字符串


调用Enum.ToString.net是相当昂贵的,因为反射被用于内部转换,并呼吁结构所引发包装虚拟方法。应尽可能避免这种情况。

枚举通常可以用常量字符串替换:

//       Numbers.One, Numbers.Two, ...
public enum Numbers
{
    One,
    Two,
    Three
}

public static class Numbers
{
    public const string One = "One";
    public const string Two = "Two";
    public const string Three = "Three";
}

如果您确实需要使用枚举,请考虑将转换后的值缓存在字典中以分摊开销。

枚举比较


注意:在.net core中,这不再相关,因为从2.1版开始,优化是由JIT自动执行的。

当使用枚举作为标志时,可能很想使用以下方法Enum.HasFlag

[Flags]
public enum Options
{
    Option1 = 1,
    Option2 = 2,
    Option3 = 4
}

private Options _option;

public bool IsOption2Enabled()
{
    return _option.HasFlag(Options.Option2);
}

此代码激发了两个带有分配的包:一个用于转换Options.Option2Enum,另一个用于虚拟调用HasFlag该结构。这使得此代码不成比例地昂贵。相反,您应该牺牲可读性并使用二进制运算符:

public bool IsOption2Enabled()
{
    return (_option & Options.Option2) == Options.Option2;
}

结构比较方法的实现


在比较中使用结构时(例如,用作字典的键时),您需要覆盖方法Equals/GetHashCode默认实现使用反射,并且非常慢。Resharper生成的实现通常非常好。

您可以在此处了解更多信息:devblogs.microsoft.com/premier-developer/performance-implications-of-default-struct-equality-in-c

使用带接口的结构时,避免包装不当


考虑以下代码:

public class IntValue : IValue
{
}

public void DoStuff()
{
    var value = new IntValue();

    LogValue(value);
    SendValue(value);
}

public void SendValue(IValue value)
{
    // ...
}

public void LogValue(IValue value)
{
    // ...
}

使IntValue结构化很容易避免分配动态内存。但是由于AddValueSendValue接口是期望的,并且接口具有引用语义,因此每次调用都会打包该值,从而抵消了这种“优化”的好处。实际上,IntValue与类相比,将有更多的内存分配,因为该值将为每个调用独立打包。

如果您正在编写API并期望某些值成为结构,请尝试使用通用方法:

public struct IntValue : IValue
{
}

public void DoStuff()
{
    var value = new IntValue();

    LogValue(value);
    SendValue(value);
}

public void SendValue<T>(T value) where T : IValue
{
    // ...
}

public void LogValue<T>(T value) where T : IValue
{
    // ...
}

尽管乍看之下将这些方法转换为通用方法似乎毫无用处,但实际上它使您避免在IntValue结构化的情况下进行分配打包

CancellationToken订阅始终为内联


当您取消时CancellationTokenSource,所有订阅都将在当前线程内执行。这可能会导致计划外的暂停甚至隐式的死锁。

var cts = new CancellationTokenSource();
cts.Token.Register(() => Thread.Sleep(5000));
cts.Cancel(); //     5 

您无法逃避这种行为。因此,在取消时CancellationTokenSource,请问自己是否可以安全地捕获当前线程。如果答案是否定的,包裹调用Cancel内部Task.Run的线程池来执行它。

TaskCompletionSource延续通常是内联的


像订阅一样CancellationToken,延续TaskCompletionSource通常是内联的。这是一个很好的优化,但是会导致隐式错误。例如,考虑以下程序:

class Program
{
    private static ManualResetEventSlim _mutex = new ManualResetEventSlim();
    
    public static async Task Deadlock()
    {
        await ProcessAsync();
        _mutex.Wait();
    }
    
    private static Task ProcessAsync()
    {
        var tcs = new TaskCompletionSource<bool>();
        
        Task.Run(() =>
        {
            Thread.Sleep(2000); //  - 
            tcs.SetResult(true);
            _mutex.Set();
        });
        
        return tcs.Task;
    }
    
    static void Main(string[] args)
    {
        Deadlock().Wait();
        Console.WriteLine("Will never get there");
    }
}

该调用tcs.SetResult导致继续await ProcessAsync()在当前线程中执行。因此,该语句_mutex.Wait()由应调用的同一线程执行_mutex.Set(),从而导致死锁。这可以通过传递参数TaskCreationsOptions.RunContinuationsAsynchronouslyc 来避免TaskCompletionSource

如果您没有充分的理由忽略它,请TaskCreationsOptions.RunContinuationsAsynchronously在创建时始终使用该选项TaskCompletionSource

请注意:如果您TaskContinuationOptions.RunContinuationsAsynchronously改用TaskCreationOptions.RunContinuationsAsynchronously代码也将编译,但是参数将被忽略,并且继续将继续内联。这是一个令人惊讶的常见错误,因为它TaskContinuationOptions先于TaskCreationOptions自动完成功能。

Task.Run / Task.Factory.StartNew


如果没有理由使用Task.Factory.StartNew,请始终选择Task.Run运行后台任务。Task.Run使用更安全的默认值,更重要的是,它会自动解包返回的任务,这可以防止异步方法产生明显的错误。考虑以下程序:

class Program
{
    public static async Task ProcessAsync()
    {
        await Task.Delay(2000);
        Console.WriteLine("Processing done");
    }
    
    static async Task Main(string[] args)
    {
        await Task.Factory.StartNew(ProcessAsync);
        Console.WriteLine("End of program");
        Console.ReadLine();
    }
}

尽管出现了外观,程序结束将在处理完成之前显示。这是因为它将Task.Factory.StartNew返回Task<Task>,并且代码仅需要外部任务。正确的代码可以是await Task.Factory.StartNew(ProcessAsync).Unwrap()await Task.Run(ProcessAsync)

只有三个有效的用例Task.Factory.StartNew

  • 在另一个调度程序中运行任务。
  • 在专用线程中执行任务(使用TaskCreationOptions.LongRunning)。
  • ( TaskCreationOptions.PreferFairness).



.



All Articles