使用Ring Buffer构建高性能的文件写入程序

最近常收到SOD框架的朋友报告的SOD的SQL日志功能报错:文件句柄丢失。经过分析得知,这些朋友使用SOD框架开发了访问量比较大的系统,由于忘记关闭SQL日志功能所以出现了很高频率的日志写入操作,从而偶然引起错误。后来我建议只记录出错的或者执行时间较长的SQL信息,暂时解决了此问题。但是作为一个热心造轮子的人,一定要看看能不能造一个更好的轮子出来。

前面说的错误原因已经很直白了,就是频繁的日志写入导致的,那么解决方案就是将多次写入操作合并成一次写入操作,并且采用异步写入方式。要保存多次操作的内容就要有一个类似“队列”的东西来保存,而一般的线程安全的队列,都是“有锁队列”,在性能要求很高的系统中,不希望在日志记录这个地方耗费多一点计算资源,所以最好有一个“无锁队列”,因此最佳方案就是Ring Buffer(环形缓冲区)了。

 什么是Ring Buffer?顾名思义,就是一个内存环,每一次读写操作都循环利用这个内存环,从而避免频繁分配和回收内存,减轻GC压力,同时由于Ring Buffer可以实现为无锁的队列,从而整体上大幅提高系统性能。Ring Buffer的示意图如下,有关具体原理,请参考此文《Ring Buffer 有什么特别? 》。

上文并没有详细说明如何具体读写Ring Buffer,但是原理介绍已经足够我们怎么写一个Ring Buffer程序了,接下来看看我在 .NET上的实现。

首先,定一个存放数据的数组,记住一定要用数组,它是实现Ring Buffer的关键并且CPU友好。

const int C_BUFFER_SIZE = 10;//写入次数缓冲区大小,每次的实际内容大小不固定
string[] RingBuffer = new string[C_BUFFER_SIZE];
int writedTimes = 0;

变量writedTimes 记录写入次数,它会一直递增,不过为了线程安全的递增且不使用托管锁,需要使用原子锁Interlocked。之后,根据每次 writedTimes 跟环形缓冲区的大小求余数,得到当前要写入的数组位置:

 void SaveFile(string fileName, string text)
 {
            int currP= Interlocked.Increment(ref writedTimes);
            int writeP= currP % C_BUFFER_SIZE ;
            int index = writeP == 0 ? C_BUFFER_SIZE - 1 : writeP - 1;
            RingBuffer[index] = " Arr[" + index + "]:" + text;
  }

Ring Buffer的核心代码就这么点,调用此方法,会一直往缓冲区写入数据而不会“溢出”,所以写入Ring Buffer效率很高。

一个队列如果只生产不消费肯定不行的,那么如何及时消费Ring Buffer的数据呢?简单的方案就是当Ring Buffer“写满”的时候一次性将数据“消费”掉。注意这里的“写满”仅仅是指写入位置 index达到了数组最大索引位置,而“消费”也不同于常见的堆栈,队列等数据结构,只是读取缓冲区的数据而不会移除它。

所以前面的代码只需要稍加改造:

 void SaveFile(string fileName, string text)
 {
            int currP= Interlocked.Increment(ref writedTimes);
            int writeP= currP % C_BUFFER_SIZE ;
            int index = writeP == 0 ? C_BUFFER_SIZE - 1 : writeP - 1;
            RingBuffer[index] = " Arr[" + index + "]:" + text;
            if (writeP == 0 )
            {
                 string result = string.Concat( RingBuffer);
                 FlushFile(fileName, result);
            }
  }

writeP == 0 表示当前一轮的缓冲区已经写满,然后调用函数 FlushFile 将Ring Buffer的数据连接起来,整体写入文件。

        void FlushFile(string fileName, string text)
        {
            using (FileStream fs = new FileStream(fileName, FileMode.Append, FileAccess.Write, FileShare.Write, 2048, FileOptions.Asynchronous))
            {
                byte[] buffer = System.Text.Encoding.UTF8.GetBytes(text);
                IAsyncResult writeResult = fs.BeginWrite(buffer, 0, buffer.Length,
                    (asyncResult) =>
                    {
                        fs.EndWrite(asyncResult);
                       
                    },
                    fs);
                //fs.EndWrite(writeResult);//这种方法异步起不到效果
                fs.Flush();
                
            }
        }

在函数 FlushFile 中我们使用了异步写入文件的技术,注意 FileOptions.Asynchronous ,使用它才可以真正利用Windows的完成端口IOCP,将文件异步写入。

当然这段代码也可以使用.NET最新版本支持的 async/await ,不过我要让SOD框架继续支持.NET 2.0,所以只好这样写了。

现在,我们可以开多线程来测试这个循环队列效果怎么样:

            Task[] arrTask = new Task[20];
            for (int i = 0; i < arrTask.Length; i++)
            {
                arrTask[i] = new Task(obj => SaveFile( (int)obj) ,i);
            }
            for (int i = 0; i < arrTask.Length; i++)
            {
                arrTask[i].Start();
            }
            
            Task.WaitAll(arrTask);
            MessageBox.Show(arrTask.Length +" Task All OK.");

这里开启20个Task任务线程来写入文件,运行此程序,发现20个线程才写入了10条数据,分析很久才发现,文件异步IO太快的话,会有缓冲区丢失,第一次写入的10条数据无法写入文件,多运行几次就没有问题了。所以还是得想法解决此问题。

通常情况下我们都是使用托管锁来解决这种并发问题,但本文的目的就是要实现一个“无锁环形缓冲区”,不能在此“功亏一篑”,所以此时“信号量”上场了。

同步可以分为锁定和信号同步,信号同步机制中涉及的类型都继承自抽象类WaitHandle,这些类型有EventWaitHandle(类型化为AutoResetEvent、ManualResetEvent)、Semaphore以及Mutex。见下图:

首先声明一个 ManualResetEvent对象:

ManualResetEvent ChangeEvent = new ManualResetEvent(true);

这里我们将 ManualResetEvent 对象设置成 “终止状态”,意味着程序一开始是允许所有线程不等待的,当我们需要消费Ring Buffer的时候再将  ManualResetEvent 设置成“非终止状态”,阻塞其它线程。简单说就是当要写文件的时候将环形缓冲区阻塞,直到文件写完才允许继续写入环形缓冲区。

对应的新的代码调整如下:

 void SaveFile(string fileName, string text)
 {
            ChangeEvent.WaitOne();
            int currP= Interlocked.Increment(ref writedTimes);
            int writeP= currP % C_BUFFER_SIZE ;
            int index = writeP == 0 ? C_BUFFER_SIZE - 1 : writeP - 1;
            RingBuffer[index] = " Arr[" + index + "]:" + text;
            if (writeP == 0 )
            {
                 ChangeEvent.Reset();
                 string result = string.Concat( RingBuffer);
                 FlushFile(fileName, result);
            }
  }

然后,再FlushFile 方法的 回掉方法中,加入设置终止状态的代码,部分代码如下:

(asyncResult) =>
                    {
                        fs.EndWrite(asyncResult);
                        ChangeEvent.Set();
                     }

OK,现在我们的程序具备高性能的安全的写入日志文件的功能了,我们来看看演示程序测试的日志结果实例:

 Arr[0]:Thread index:0--FFFFFFF
 Arr[1]:Thread index:1--FFFFFFF
 Arr[2]:Thread index:8--FFFFFFF
 Arr[3]:Thread index:9--FFFFFFF
 Arr[4]:Thread index:3--FFFFFFF
 Arr[5]:Thread index:2--FFFFFFF
 Arr[6]:Thread index:4--FFFFFFF
 Arr[7]:Thread index:10--FFFFFFF
 Arr[8]:Thread index:5--FFFFFFF
 Arr[9]:Thread index:6--FFFFFFF
 Arr[0]:Thread index:7--FFFFFFF
 Arr[1]:Thread index:11--FFFFFFF
 Arr[2]:Thread index:12--FFFFFFF
 Arr[3]:Thread index:13--FFFFFFF
 Arr[4]:Thread index:14--FFFFFFF
 Arr[5]:Thread index:15--FFFFFFF
 Arr[6]:Thread index:16--FFFFFFF
 Arr[7]:Thread index:17--FFFFFFF
 Arr[8]:Thread index:18--FFFFFFF
 Arr[9]:Thread index:19--FFFFFFF

测试结果符合预期! 到此,我们今天的主题就全部介绍完成了,不过要让本文的代码能够符合实际的运行,还要解决每次只写入少量数据并且将它定期写入日志文件的问题,这里贴出真正的局部代码:

PS:有朋友说采用信号量并不能完全保证程序安全,查阅了MSDN也说如果信号量状态改变还没有来得及应用,那么是起不到作用的,所以还需要检查业务状态标记,也就是在设置非终止状态后,马上设置一个操作标记,在其它线程中,需要检查此标记,以避免“漏网之鱼”引起不期望的结果。

再具体实现上,我们可以实现一个“自旋锁”,循环检查此状态标记,为了防止发生死锁,还需要有锁超时机制,代码如下:

 void SaveFile(string fileName, string text)
        {
            ChangeEvent.WaitOne(10000);
            int currP= Interlocked.Increment(ref WritedTimes);
            int writeP= currP % C_BUFFER_SIZE ;
            int index = writeP == 0 ? C_BUFFER_SIZE - 1 : writeP - 1;
           
            if (writeP == 0 )
            {
                ChangeEvent.Reset();
                IsReading = true;
                RingBuffer[index] = " Arr[" + index + "]:" + text;

                LastWriteTime = DateTime.Now;
                WritingIndex = 0;
                SaveFile(fileName,RingBuffer);
            }
            else if (DateTime.Now.Subtract(LastWriteTime).TotalSeconds > C_WRITE_TIMESPAN)
            {
                ChangeEvent.Reset();
                IsReading = true;
                RingBuffer[index] = " Arr[" + index + "]:" + text;

                int length = index - WritingIndex + 1;
                if (length <= 0)
                    length = 1;
                string[] newArr = new string[length];
                Array.Copy(RingBuffer, WritingIndex, newArr, 0, length);

                LastWriteTime = DateTime.Now;
                WritingIndex = index + 1;
                SaveFile(fileName, newArr);
            }
            else
            {
                //防止漏网之鱼的线程在信号量产生作用之前修改数据
                //采用“自旋锁”等待
                int count = 0;
                while (IsReading)
                {
                    if (count++ > 10000000)
                    {
                        Thread.Sleep(50);
                        break;
                    }
                }
                RingBuffer[index] = " Arr[" + index + "]:" + text;
            }
        }

完整的Ring Buffer代码会在最新版本的SOD框架源码中,有关本篇文章测试程序的完整源码,请加QQ群讨论获取,

群号码:SOD框架高级群 18215717 ,加群请注明 PDF.NET技术交流 ,否则可能被拒绝。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏更流畅、简洁的软件开发方式

.net Framework2.0 里面的Webcontrols的几个常用控件的继承树

      为了写好分页控件的自动绑定的功能,研究了一下几个控件的继承关系,目的就是为了看看DataSource到底是从哪里来的。 ?       层数还真是多...

2077
来自专栏nice_每一天

教你开发jQuery插件(转) 教你开发jQuery插件(转)

原文:http://www.cnblogs.com/Wayou/p/jquery_plugin_tutorial.html

1781
来自专栏雨过天晴

原 荐 PHP 在 Console 模式下的

1731
来自专栏尾尾部落

[剑指offer] 矩形覆盖

我们可以用2 * 1的小矩形横着或者竖着去覆盖更大的矩形。请问用n个2 * 1的小矩形无重叠地覆盖一个2 * n的大矩形,总共有多少种方法?

1032
来自专栏Android知识点总结

Android基于TCP的五子棋双人对战实现

1922
来自专栏Spring相关

Springboot用官方建议访问Html页面并接传值

我们以前通常习惯用webapp来放置jsp页面,但是到了Springboot中,官方建议用Static文件夹来存放及静态的资源,

6364
来自专栏机器学习算法与Python学习

Python:爬虫系列笔记(8) -- 爬去MM图片

转载于:静觅 » Python爬虫实战四之抓取淘宝MM照片 链接:http://cuiqingcai.com/1001.html 1.抓取淘宝MM的姓名,头像,...

4316
来自专栏熊二哥

快速入门系列--MVC--06视图

到了View的呈现板块,感觉ASP.NET MVC的学习也进入了尾声,还是比较开心的,毕竟也有了不小收获。这部分内容相对比较简单,因为之前还专门学习过如何结合...

20410
来自专栏aCloudDeveloper

数据对齐详解

Author:bakari           Date:2012.8.26 数据对齐实际上是内存字节的对齐,今天偶然翻开自己以前做的笔记,发现做了好多的题,但...

23710
来自专栏CodingBlock

Android开发必知--使用View.setId的正确姿势

  这两天在写一个柱状图的自定义控件,用的直接继承ViewGroup的方式实现的,我们都知道,这是自定义控件里面最简单的一种了,有时间写个总结分享一下。这里我想...

25810

扫码关注云+社区

领取腾讯云代金券