今天在线上环境又吃了一个bug,程序之前运行良好,最近在增加了数据量之后,启动的时候就直接报了OutOfMemory的bug,但明明内存占用只有100多M。在查看日志后发现,在调用Thread.Start创建线程的时候报了OutOfMemoryException,这里记录一下如何查找原因以及对应的修改方法。

问题的产生


    这个程序有一个集合,集合里面是一个实体对象,每个实体对象启动后会启动3个线程。原先只有200多个这样的实体,最近又新增了将近300多个,总数达到了将近500个实体,这样,程序启动的时候,理论上将会创建多达1500个线程,当程序创建到某一数量的线程数时,如果继续创建,就会抛出OutOfMemoryException,这里要吐槽一下,这个“OutOfMemoryException”错误非常笼统,它不单是指超过了物理内存,还有很多种原因会报这个错误,比如申请开辟大数据对象等等。在我这个例子里,整个计算机有大约30G的空闲内存,而整个程序只占用了不到100多M的内存,为什么不报一个更加明确的错误,比如ExccedTheMaxThreadCountExcption。

System.OutOfMemoryException
  HResult=0x8007000E
  Message=Exception_WasThrown
  Source=mscorlib
  StackTrace:
   在 System.Threading.Thread.StartInternal(IPrincipal principal, StackCrawlMark& stackMark)
   在 System.Threading.Thread.Start(StackCrawlMark& stackMark)
   在 System.Threading.Thread.Start()
   在 BaseLib.Helper.StartThread(String name, ThreadStart start) 在 xxx\Helper.cs 中: 第 547 行

     那么问题就来了,一个.NET应用程序究竟能允许创建多少个线程?

.NET程序中最大允许创建线程数量


    对于计算机来说,线程资源很宝贵,CLR当然不能让应用程序无节制的创建线程,所以这里肯定会有一个允许创建的最大线程数量。可以通过ThreadPool.GetMaxThreads方法获取:

ThreadPool.GetMaxThreads(out int workThread,out int completionPortThread);
Console.WriteLine($"MaxWorkThreads:{workThread},MaxCompletionPortThreads:{completionPortThread}");

     当以x86编译时,输出结果为:

MaxWorkThreads:1023,MaxCompletionPortThreads:1000

    当以x64编译时,输出结果为:

MaxWorkThreads:32767,MaxCompletionPortThreads:1000

     可以看到,当程序以32位编译时,C#单个程序(AppDomain)允许创建的最大工作线程数为1023个,如果是64位,则为32767个,IO线程都是1000个。我的程序当时是以32位编译的,所以超过创建超过1023个线程就会报OutOfMemoryException的错误(虽然创建了1000多个线程,但内存占用只有100多M,CLR Via C#里面说1个线程会占用1.5M的内存,我这里怎么看不到?)。在改为了64位编译后就没有这个错误了,实际上程序总共创建了1400多个线程,太可怕了😂🤣。

优化


    首先第一个要优化的就是,将编译时x86改为x64编译,现在的计算机大部分都是64位的平台了,所以最好都编译成64位,这样一些诸如内存,线程的限制就会款很多。

     第二个就是对程序进行优化,现在看来,这个程序有优化的空间,拿我这个程序来说,一个对象里面包含了三个线程,其中一个线程是一个定时的操作,他会等待到某一个时间比如9:25:00来执行某一个操作,如果不满足条件,则直接退出,如果满足条件,该线程也退出,剩下的2个线程继续工作。如果我添加500多个对象,那么就有1500个线程,其中500个线程,会在同一时间执行某一操作,一个优化的步骤就是,把这个定时线程优化掉,不要在对象内部处理,而是提供一个定时操作的方法,在外面调用。比如优化为了如下:

     优化前,每一个OpenCallAuction对象都有一个专门线程,执行如下定时方法:

private void ProcessLastAuction()
{
    if (stopME.WaitOne(openAuctionEndTime - DateTime.Now.TimeOfDay))
    {
        return;
    }
    try
    {
       CheckOpenSignal();     
    }
    catch (ThreadAbortException)
    {

    }
    catch (Exception e)
    {
        LogError(e);
    }
}

    优化后,去掉该线程,在OpenCallAuctionManager中,起一个定时线程来,循环调用OpenCallAuction里面的CheckOpenSignal方法。

private void ProcessLastAuction()
{
    if (stopME.WaitOne(openAuctionEndTime - DateTime.Now.TimeOfDay))
    {
        return;
    }
    try
    {
        Parallel.For(0, watchStocks.Count, i =>
        {
            watchStocks[i].CheckOpenSignal();
        });
    }
    catch (ThreadAbortException)
    {

    }
    catch (Exception e)
    {
        LogError(e);
    }
}

       可以看到,之前是每一个OpenCallAuction对象都有一个这样的定时操作线程,在OpenAuctionEndTime到来的时候,执行CheckOpenSignal操作。现在我把OpenCallAuction对象内部的这个线程移除掉,提供一个public的CheckOpenSignal方法,在外面单独开一个线程,然后等到OpenAuctionEndTime到来时,用Parallel.For来并行处理(注意Parallel.Foreach比For要慢一点)。这样就优化掉了三分一的线程创建。当然这里需要注意的是,CheckOpenSignal里面不要有耗时的操作。

      另外优化是,原来的程序比如500个对象,会在OpenAuctionEndTime到来时统一在同一时间处理,后来仔细分析需求发现,对于有些对象,如果在此之前收到了某些数据,就可以优先开始执行CheckOpenSignal操作,这样一部分可以在OpenAuctionEndTime时间到来之前就进行处理,另外一部分在OpenAuctionEndTime的时候最为兜底处理。这样就减少了并行性。

总结


      在开发应用程序时,由于32位程序有可用内存大小,线程数量等方面的比较严格的限制,在写程序时,如果能够以64位发布,那么优先以64位发布(当然有些如果引用的第三方dll不支持64位,或者计算机平台不支持64位另当别论)。另外在写程序的时候,如果遇到需要单独创建线程资源,而不是使用线程池时要慎重考虑,需要考虑手动管理线程的资源开销。在某些情况下要先做压力测试,看下程序能够支持的最大处理能力,这样可以避免当处理数据量增大时,程序性能下降或者崩溃而带来的损失。最后,要结合特定的业务,找到能优化线程开销的地方,从而减少线程创建的压力,或者并行的压力,从而提升性能。

 

参考资料: