词频统计程序是一个相当简单的程序:它读一个文件夹里的所有指定类型的文件,统计其中出现的英文单词的次数,并排序输出。
但是它却有很大的优化余地,甚至可以分布式到多台机器中(Map-Reduce模型)。但是,在单机中搞这么复杂反而会增加运行时间和内存。
我们希望将它改造成多线程。但是,分词过程和统计过程若分布到多个线程中,则对内存的锁会增加,因为大部分算法的时间效率都是O(n)的,而且对内存的操作很频繁,所以效率反而会降低。
在单机中,若为单线程运行,则IO操作(读写文件)时不能进行CPU运算,同理CPU进行运算时不能进行IO操作。所以一种很明显的优化方式就是,把IO操作和CPU操作分离到两个线程中去。所以,我尝试了这个优化方案。
由于之前在暑假学习过Scala语言,所以对Scala中的Actor模型印象很深刻。我希望在C#中也实现Actor模型,从而轻松的实现线程之间的通信。
Actor模型是啥?它把每一个的线程都当做一个Actor,每个Actor含有一个队列。Actor的工作是,循环的从队列里拿出队列头的数据并对它进行工作。同时,它还可以与其他Actor通信,传递给它数据(即把数据加入另一个Actor的队列尾中)。
这样的模型很适合这个双线程词频统计程序:IO线程读取文件内容随后把内容发给分析线程,分析线程对其进行分析。两个线程的工作都完成后,把分析好的结果输出。
这个优化的核心是Actor类型。定义如下:
public abstract class Actor{ System.Collections.Concurrent.BlockingCollection messages; System.Threading.Thread th; public Actor(int maxmsgcount = 100,int timeout=-1) { messages = new System.Collections.Concurrent.BlockingCollection (maxmsgcount); th = new System.Threading.Thread(new System.Threading.ThreadStart(() => { T current; while(true) if (messages.TryTake(out current,timeout)) HandleMessage(current); })); th.Start(); } public bool PostMessage(T msg,int timeout=-1) { return messages.TryAdd(msg, timeout); } public abstract void HandleMessage(T msg); public void Close() { th.Abort(); th = null; } }
其中使用了C# 4.0中新增的BlockingCollection
随后,我构建了两个Actor对象:一个负责IO,一个负责计算:
public class FileIOActor : Actor<string> { CPUActor actor; public FileIOActor(CPUActor cpuactor) { this.actor = cpuactor; } public override void HandleMessage(string msg) { if (msg == "END") { actor.PostMessage(""); return; } System.IO.StreamReader reader = new System.IO.StreamReader(msg); while (!reader.EndOfStream) { string str = reader.ReadToEnd(); actor.PostMessage(str+" "); } reader.Close(); } public void Close() { base.Close(); actor.Close(); } }
public class CPUActor : Actor<string> { static int[] lettertype = new int[128]; static CPUActor() { for (int i = 'a'; i <= 'z'; i++) lettertype[i] = 1; for (int i = 'A'; i <= 'Z'; i++) lettertype[i] = 1; for (int i = '0'; i <= '9'; i++) lettertype[i] = 2; } //定义单词 public class Word:IComparable{ public Word(string w) { str = w; } public string str; public int Count=0; public int CompareTo(Word obj) { if (obj.Count != this.Count) return Count - obj.Count; else return String.CompareOrdinal(obj.str, str); } } //计算结束后的事件 Action > finish; public CPUActor(Action > classes) { finish = classes; } int state=0; StringBuilder sb = new StringBuilder(100); Dictionary<string, Word> classes = new Dictionary<string, Word>(10000); public override void HandleMessage(string msg) { if (msg == "") { finish(classes.Values); return; } for (int i = 0; i < msg.Length; i++) { //状态机 switch (state) { case 0: if ((state = ((msg[i] & 0x7f) != msg[i]) ? 0 : lettertype[msg[i]]) != 0) { sb.Clear(); sb.Append(msg[i]); } break; default: if ((state = ((msg[i] & 0x7f) != msg[i]) ? 0 : lettertype[msg[i]]) == 0) { if (sb.Length >= 3) take(sb.ToString()); } else sb.Append(msg[i]); break; } } } public void take(string word) { if (!(lettertype[word[0]] == 1 && lettertype[word[1]] == 1 && lettertype[word[2]] == 1)) return; string lword = word.ToLower(); Word w = null; if (classes.TryGetValue(lword, out w)) { if (String.CompareOrdinal(word, w.str) < 0) w.str = word; w.Count++; } else { classes.Add(lword, w = new Word(word)); w.Count = 1; } } }
其中,读文件时我采用了ReadToEnd方法,若文件过大可很容易的换成ReadBlock方法等。统计时只考虑第一种需求,考虑末尾的数字。
剩下的就是主程序了,很简单:
......... var files = from f in System.IO.Directory.GetFiles(dir, "*", System.IO.SearchOption.AllDirectories) where f.EndsWith(".cpp", StringComparison.OrdinalIgnoreCase) || f.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) || f.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) || f.EndsWith(".h", StringComparison.OrdinalIgnoreCase) select f; FileIOActor fileactor = null; fileactor = new FileIOActor(new CPUActor(a => { var collection = from word in a orderby word descending select new StringBuilder(word.str).Append(": ").Append(word.Count); System.IO.StreamWriter writer = new System.IO.StreamWriter(username + ".txt"); if (collection.Count() > 0) foreach (var str in collection) writer.WriteLine(str); writer.Close(); if (fileactor != null) fileactor.Close(); })); foreach (var i in files) fileactor.PostMessage(i); fileactor.PostMessage("END"); .........
于是,这个程序做好了。经过测试,它的答案与原来一致。在我的双核I5CPU下,用一个133MB的文件夹测试,对CPU性能分析结果如下:
多线程优化前:
优化后:
可以看出,优化效果还是很明显的。这也是在我电脑上唯一一个对于这个数据可以跑进10秒的词频统计程序。
优化前后的瓶颈都在于String.ToLower()函数:
通过黄杨的另一个优化方案,速度可以更快些。