4.3 HashFold
在 4-1 中,我们介绍了大量信息被创造和记录所引发的“信息爆炸”,以及为应付信息爆炸而将处理分布到多台计算机中进行的方法。对于运用多台计算机构成的高度分布式处理环境中的编程模型,我们介绍了美国 Google 公司提出的 MapReduce。下面我们要介绍的 HashFold 正是它的一种变体,Steve Krenzel 在其网站中(http://stevekrenzel.com/improving-mapreduce-with-hashfold)也对此做了介绍。
MapReduce 通过分解、提取数据流的 Map 函数和化简、计算数据的 Reduce 函数,对处理进行分割,从而实现了对大量数据的高效分布式处理。
相对地,HashFold 的功能是以散列表的方式接收 Map 后的数据,然后通过 Fold 过程来实现对散列表元素的去重复。这种模型将 MapReduce 中一些没有细化的部分,如 Map 后的数据如何排序再进行 Reduce 等,通过散列表这一数据结构的性质做了清晰的描述,因此我个人很喜欢 HashFold。不过,虽然我对 HashFold 表示支持,但恐怕它要成为主流还是很困难的。
即便无法成为主流,对于大规模数据处理中分布式处理的实现,HashFold 简洁的结构应该也可以成为一个不错的实例。
HashFold 的 Map 过程在接收原始数据之后,将数据生成 key、value 对。然后,Fold 过程接收两个 value,并返回一个新的 value。图 1 所示的就是一个运用 HashFold 的单词计数程序。
def map(document) ←---接收文档并分解为单词 # document: document contents for word in document # key=单词,计数=1 yield word, 1 end end def fold(count1, count2) ←---对单词进行统计 # count1, count2: two counts for a word return count1 + count2 end图 1 用 HashFold 编写的单词计数程序(概念)
单词计数是 MapReduce 主要的应用实例,这个说法已经是老生常谈了,每次提到 MapReduce 的话题,就会把它当成例题来用。而 HashFold 则是单词计数的最佳计算模型,当然,它也可以用来进行其他的计算。
下面我们按照图 1 的概念,来实现一个简单的 HashFold 库。因为如果不实际实现一下的话,我们就无法判断这种模型是否具有广泛的适应性。
为了进行设计,我们需要思考满足 HashFold 性质的条件,于是便得出了以下结论。
首先,由于在 Ruby 中无法通过网络发送过程,因此 HashFold 的主体不应是函数(过程),而应该是对象。如果是对象的话,只要通过某些手段事先共享类定义,我们就可以用 Ruby 中内建的 Marshal 功能通过网络来传输对象了。
我们希望这个对象最好是 HashFold 类的子类。这样一来,HashFold 类所拥有的功能就可以被继承下来,从而可以使用 Template Method 模式来提供每个单独的 Map 和 Fold(图 2)
class WordCount < HashFold def map(document) ←---分割单词 for word in document yield word, 1 end end def fold(count1, count2) ←---重复的单词进行合并计算 return count1 + count2 end end h = WordCount.new.start(documents) ←---得到结果Hash(单词=>单词出现数)图 2 HashFold 库 API(概念)
唔,貌似挺好用的。下面就我们来制作一个满足上述设计的 HashFold 库。
HashFold 库的实现(Level 1)
好,我们已经完成了 API 的设计,现在我们来实际进行 HashFold 库的实现吧。首先,我们先不考虑分布式环境,而是先从初级版本开始做起。
要实现一个最单纯级别的 HashFold 是很容易的。只要接收输入的数据,并对其执行 Map 过程,如果出现重复则通过 Fold 过程来解决。实际的程序如图 3 所示。
class HashFold def start(inputs) hash = {} ←---保存结果用的Hash inputs.each do |input| ←---对传递给start的各输入数据调用map方法 self.map(input) do |k,v| if hash.key?(k) ←---在代码块中传递的key和value如果出现重复则调用fold方法 hash[k] = self.fold(hash[k], v) else hash[k] = v ←---如果尚未存在则存放到Hash中 end end end hash ←---返回结果Hash end end图 3 HashFold 库(Level 1)
在不考虑分布式环境的情况下,HashFold 的实现其实相当容易,这也反映了 HashFold“易于理解”这一特性。
不过,不实际运行一下,就不知道它是不是真的能用呢。于是我们准备了一个例题程序。
图 4 就是为 HashFold 库准备的例题程序,它可以看成是对图 2 中的概念进行具体化的结果。这是一个依照 MapReduce 的传统方式,对单词进行计数的程序。今后我们会对 HashFold 库进行升级,但其 API 是不变的,因此单词计数程序也不需要进行改动。
class WordCount < HashFold 不需要进行计数的高频英文单词 STOP_WORDS = %w(a an and are as be for if in is it of or the to with) 将输入的参数作为文件名 def map(document) open(document) do |f| ←---对文件各行执行操作 for line in f ←---将所有标点符号视为分割符(替换成空格) line.gsub!(/[!#"$%&\'()*+,-.\/:;<=>?@\[\\\]^_`{\|}~]/, " ") ←---将一行的内容分割为单词 for word in line.split ←---将字母都统一转换为小写 word.downcase! ←---高频单词不计数 next if STOP_WORDS.include?(word) ←---key=单词,计数=1,传递给代码块 yield word.strip, 1 ←---解决重复 end end end end def fold(count1, count2) ←---对单词计数进行简单累加 return count1 + count2 ←---命令行参数用于指定要统计单词的文件。随后按照计数将单词倒序排列(从大到小),并输出排在前20位的单词。 end end WordCount.new.start(ARGV).sort_by{|x|x[1]}.reverse.take(20).each do |k,v| print k,": ", v, "\n" end图 4 单词计数程序
将图 3 的库和图 4 的程序结合起来,就完成了一个最简单的 HashFold 程序。我们暂且将这个程序保存在“hfl.rb”这个文件中。
那么,我们来运行一下看看。首先将 Ruby 代码仓库中的“Ruby trunk”分支下的 ChangeLog(变更履历)作为单词计数的对象。运行结果如图 5 所示。
% ruby hf1.rb ChangeLog ruby: 11960 rb: 11652 c: 10231 org: 7591 lang: 5494 test: 4224 lib: 3804 ext: 3582 2008: 3172 ditto: 2669 dev: 2382 nobu: 2334 nakada: 2313 nobuyoshi: 2313 2007: 1820 h: 1664 matz: 1659 yukihiro: 1648 matsumoto: 1648 tue: 1639图 5 单词计数的运行结果
从这个结果中,我们可以发现很多有趣的内容。比如,ruby 这个单词出现次数最多,这是理所当然的,而出现次数最多的名字(提交者)是 nobuyoshi nakada(2313 次),远远超出位于第二名的我(1648 次)。原来我已经被超越那么多了呀。
除此之外,我们还能看出提交发生最多的日子是星期二。如果查看一下 20 位之后的结果,就可以看出一周中每天提交次数的排名:最多的是星期二(1639 次),然后依次是星期四(1584 次)、星期一(1503 次)、星期五(1481 次)、星期三(1477 次)、星期六(1234 次)和星期日(1012 次)。果然周末的提交比较少呢,但次数最多的居然是星期二,这个倒是我没有想到的。
不过,光统计一个文件中的单词还不是很有意思,我们来将多个文件作为计数对象吧,比如将 ChangeLog 以及其他位于 Ruby trunk 分支中所有的“.c”文件作为对象。我算了一下,要统计的文件数量为 292 个,大小约 6MB,正好我们也可以来统计一下运行时间(图 6)。这里我们的运行环境是 Ruby 1.8.7,Patch Level 174。
% time ruby hf1.rb ChangeLog **/*.c rb: 31202 0: 17155 1: 13784 ruby: 13205 (中略) ruby hf0.rb ChangeLog **/*.c 37.89s user 3.89s system 98% cpu 42.528 total图 6 以多个文件为对象的运行结果(附带运行时间)
我用的 shell 是“zsh”,它可以通过“**/*.c”来指定当前目录下(包括子目录下)所有的 .c 文件。这个功能非常方便,甚至可以说我就是为了这个功能才用 zsh 的吧。
在命令行最前面加上 time 就可以测出运行时间。time 是 shell 的内部命令,因此每种 shell 输出的格式都不同,大体上总会包含以下 3 种信息。
· user:程序本身所消耗的时间
· system:由于系统调用在操作系统内核中所消耗的时间
· total:从程序启动到结束所消耗的时间。由于系统中还运行着其他进程,因此这个时间要大于 user 与 system 之和
从这样的运算量来看,用时 42 秒还算不赖。不过,6MB 的数据量,即便不进行什么优化,用简单的程序来完成也没有多大问题。
作为参考,我用 Ruby 1.9 也测试了一下,所用的是写稿时最新的 trunk,ruby1.9.2dev(2009-08-08trunk 24443)。
运行结果为 user 18.61 秒、system 0.14 秒、total 18.788 秒,也就是说,和 Ruby 1.8.7 的 42.528 秒相比,速度达到了两倍以上(2.26 倍)。看来 Ruby 1.9 中所搭载的虚拟机“YARV(Yet Another Ruby VM)”的性能不可小觑呢。
从此之后,我们基本上都使用 1.9 版本来进行测试,主要是因为我平常最常用的就是 Ruby 1.9。此外,由于性能测试要跑很多次,如果等待时间能缩短的话可是能大大提高(写稿的)生产效率的。
运用多核的必要性
如果程序运行速度变快,恐怕没人会有意见。相反,无论你编写的程序运行速度有多快,总会有人抱怨说“还不够快啊”。这种情况的出现几乎是必然的,就跟太阳每天都会升起来一样。
问题不仅仅如此。虽然 CPU 的速度根据摩尔定律而变得越来越快,但也马上就要遇到物理定律的极限,CPU 性能的提升不会像之前那样一帆风顺了。这几年来,CPU 时钟频率的提升已经遇到了瓶颈,Intel 公司推出的像 Atom 这样低频率、低能耗的 CPU 的成功,以及让普通电脑也能拥有多个 CPU 的多核处理器的普及,这些都是逐步接近物理极限所带来的影响。
此外,还有信息爆炸的问题摆在我们面前。当要处理的数据量变得非常巨大时,光数据传输所消耗的时间都会变得无法忽略了。在 Google 公司所要处理的 PB 级别数据量下,光是数据的拷贝所花费的时间,就能达到“天”这个数量级。
MapReduce 正是在这一背景下诞生的技术,HashFold 也需要考虑到这方面因素而不断提升性能。
所幸的是,我所用的联想 ThinkPad X61 安装了 Intel Core2 Duo 这个双核 CPU,没有理由不充分利用它。通过使用多个 CPU 进行同时处理,即并发编程,为处理性能的提高提供了新的可能性。
目前的 Ruby 实现所存在的问题
然而,从充分利用多核的角度来看,目前的 Ruby 实现是存在问题的。作为并发编程的工具,我们可以使用线程,但 Ruby 1.8 中的线程功能是在解释器级别上实现的,因此只能同时进行一项处理,并不能充分利用多核的性能。在 Ruby 1.9 中,线程的实现使用了操作系统提供的 pthread 库,本来应该是可以利用多核的,但在 Ruby 1.9 中,为了保护解释器中非线程安全的部分而加上了一个称为 GIL(Giant Intepreter Lock)1的锁,由于这个锁的限制,每次还是只能同时执行一个线程,看来在 Ruby 1.9 中要利用多核也是很困难的。
1 这个锁的功能是,在多线程环境中,为了保护非线程安全(没有考虑到多线程同时并行运行)的代码,只允许同时运行一个线程。(原书注)
那么,如果要在 Ruby 上利用多核,该怎样做呢?一种方法是采用没有加 GIL 锁的实现。所幸,在 JVM 上工作的 JRuby 就没有这个锁,因此用 JRuby 就可以充分利用多核了。不过,我作为 Ruby 的实现者,在这一点上却非要使用 JRuby 不可,总有点“败给它了”的感觉。
通过进程来实现 HashFold(Level 2)
“如果线程不行的话那就用进程好了。”不过,仔细想想就会发现,利用多个 CPU 的手段,操作系统不是原本就已经提供了吗?那就是进程。如果启动多个进程,操作系统就会自动进行调配,使得各个进程分配到适当的 CPU 资源。
这样的功能不利用起来真是太浪费了。首先,我们先来做一个最简单的进程式实现,即为每个输入项目启动一个进程。
为每个输入启动一个进程的 HashFold 实现如图 7 所示。和线程不同,进程之间是不共享内存的,因此为了返回结果就需要用到进程间通信。在这里,我们使用 UNIX 编程中经典的父子进程通信手段 pipe。
基本处理流程很简单。对各输入启动相应的进程,各个文件的单词计数在子进程中进行。计数结果的 Hash 需要返回给父进程,但和线程不同,父子进程之间无法共享对象,因此需要使用 pipe 和 Marshal 将对象进行复制并转发。父进程从子进程接收 Hash 后,将接收到的 Hash 通过 fold 方法进行合并,最终得到单词计数的结果。
说到这里,大家应该明白图 7 程序的大致流程了。而作为编程技巧,希望大家记住关于 fork 和 pipe 的用法,它们在使用进程的程序中几乎是不可或缺的技巧。在 Ruby 中,fork 方法可以附带代码块来进行调用,而代码块可以只在子进程中运行,当运行到代码块末尾时,子进程会自动结束。
class HashFold def hash_merge(hash,k,v) ←---调用fold,由于要调用多次,因此构建在方法中 if hash.key?(k) hash[k] = self.fold(hash[k], v) ←---如果遇到重复则调用fold方法 else hash[k] = v ←---尚未存在则存放到Hash中 end end def start(inputs) hash = nil inputs.map do |input| ←---对传递给start的每个输入进行循环 p,c = IO.pipe ←---创建用于父子进程间通信用的pipe fork do ←---创建子进程(fork),在子进程中运行代码块 p.close ←---关闭不使用的pipe h = {} ←---保存结果用的Hash self.map(input) do |k,v| ←---调用map方法,由于完全复制了父进程的内存空间,因此可以看到父进程的对象(input) hash_merge(h,k,v) ←---存放数据,解决重复 end Marshal.dump(h,c) ←---将结果返回给父进程,这次使用Marshal end c.close ←---这是父进程,关闭不使用的pipe p ←---对父进程一侧的pipe进行map end.each do |f| ←---读取来自子进程的结果 h = Marshal.load(f) if hash ←---将结果Hash进行合并 h.each do |k,v| hash_merge(hash, k, v) end else hash = h end end hash end end #单词计数的部分是共通的图 7 运用进程实现的 HashFold
重要的事情总要反复强调一下,fork 的作用是创建一个当前运行中的进程的副本。由于是副本,因此现在可以引用的类和对象,在子进程中也可以直接引用。但是,也正是由于它只是一个副本,因此如果对对象进行了任何变更,或者创建了新的对象,都无法直接反映到父进程中。这一点,和共享内存空间的线程是不同的。
在不共享内存空间的进程之间进行信息的传递有很多种方法,在具有父子关系的进程中,pipe 恐怕是最好的方法了。
pipe 方法会创建两个分别用来读取和写入的 IO(输入输出)。
r,w = IO.pipe在这两个 IO 中,写入到 w 的数据可以从 r 中读取出来。正如刚才所讲过的,由于子进程是父进程的副本,在父进程中创建 pipe,并在子进程中对 pipe 进行写入的话,就可以从父进程中将数据读取出来了。作为好习惯,我们应该将不使用的 IO(在这里指的是父进程中用于写入的,和子进程中用于读取的)关闭掉,避免对资源的浪费。
在这个程序中,从子进程传递结果只需要创建一对 pipe,如果需要双向通信则要创建两对 pipe。
好,我们来运行一下看看。将图 7 的 HashFold 和图 4 的单词计数程序组合起来保存为“hf2.rb”,并运行这个程序。在 1.9 环境下的运行结果为 user 0.66 秒、system 0.08 秒、total 11.494 秒。和非并行版的运行时间 18.788 秒相比,速度是原来的 1.63 倍。考虑到并行处理产生的进程创建开销,以及 Marshal 的通信开销,63% 的改善还算是可以吧。
之所以 user 和 system 时间非常短,是因为实际的单词计数处理几乎都是在子进程中进行的,因此没有被算进去。顺便,在 1.8.7 上的运行时间是 25.528 秒,是 1.9 上的 2.25 倍。
然而,仔细看一看的话,这个程序还是有一些问题的。这个程序中,对每一个输入文件都会启动一个进程,这样会在瞬间内产生大量的进程。这次我们对 292 个文件的单词进行计数,创建了 293 个(文件数量 + 管理进程)进程,而大量的进程则意味着巨大的内存开销。如果要统计的对象文件数量继续增加,就会因为进程数量太多而引发问题。
抖动
当进程数量过多时,就会产生抖动现象。
随着大量进程的产生,会消耗大量的内存空间。在最近的操作系统中,当申请分配的内存数量超过实际的物理内存容量时,会将现在未使用的进程的内存数据暂时存放到磁盘上,从表面上看,可用内存空间变得更多了。这种技术被称为虚拟内存。
然而,磁盘的访问速度和实际的内存相比要慢上几百万倍。当进程数量太多时,几乎所有的时间都消耗在对磁盘的访问上,实际的处理则陷于停滞,这就是抖动。
其实,用 Ruby 只需要几行代码就可以产生大量的进程,从而故意引发抖动,不过在这里我们还是不介绍具体的代码了。
当然,操作系统方面也考虑到了这一点。为了尽量避免发生抖动,也进行了一些优化。例如写时复制(Copy-on-Write)技术,就是在创建子进程时,对于所有的内存空间并非一开始就创建副本,而是先进行共享,只有当实际发生对数据的改写时才进行复制。通过这一技术,就可以避免对内存空间的浪费。
在 Linux 中还有一个称为 OOM Killer(Out of Memory Killer)的功能。当发生抖动时,会选择适当的进程并将其强制结束,从而对抖动做出应对。当然,操作系统不可能从人类意图的角度来判断哪个进程是重要的,因此 OOM Killer 有时候会错杀掉一些很重要的进程,对于这个功能的评价也是毁誉参半。
运用进程池的 HashFold(Level 3)
大量产生进程所带来的问题我们已经了解了。那么,我们可以不每次都创建进程然后舍弃,而是重复利用已经创建的进程。线程和进程在创建的时候就伴随着一定的开销,因此像这样先创建好再重复利用的技术是非常普遍的。这种重复利用的技术被称为池(pooling)(图 8)。
class HashFold class Pool ←---用于进程池的类 def initialize(hf, n) ←---初始化,指定HashFold对象以及进程池中的进程数量 pool = n.times.map{ ←---创建n个进程 c0,p0 = IO.pipe ←---通信管道:从父进程到子进程(输入) p1,c1 = IO.pipe ←---通信管道:从子进程到父进程(输出) fork do ←---创建子进程 p0.close ←---关闭不使用的pipe p1.close loop do ←---重复利用,执行循环 input = Marshal.load(c0) rescue exit ←---用Marshal等待输入,输入失败则exit hash = {} ←---保存结果用的Hash hf.map(input) do |k,v| ←---调用HashFold对象的nap方法 hf.hash_merge(hash,k,v) ←---数据保存,解决重复 end Marshal.dump(hash,c1) ←---将结果返回父进程 end end c0.close ←---父进程中也关闭不使用的pipe c1.close [p0,p1] ←---对输入输出用的pipe进行map } @inputs = pool.map{|i,o| i} ←---向进程池写入用的IO @outputs = pool.map{|i,o| o} ←---由进程池读出用的IO @ichann = @inputs.dup ←---可以向进程池写入的IO @queue = [] ←---写入队列 @results = [] ←---读出队列 end def flush ←---将写入队列中的数据尽量多地写入 loop do if @ichann.empty? o, @ichann, e = IO.select([], @inputs, []) ←---使用select寻找可写的IO(a) break if @ichann.empty? ←---如果没有可写的IO则放弃 end break if @queue.empty? ←---如果不存在要写入的数据则跳出循环 Marshal.dump(@queue.pop, @ichann.pop) ←---可写则执行写入 end end private :flush ←---这是一个用作内部实现的方法,因此声明为private def push(obj) ←---向Pool写入数据的方法 @queue.push obj flush end def fill ←---从读出队列中尽量多地读出数据 t = @results.size == 0 ? nil: 0 ←---result队列为空时用select阻塞,不为空时则只检查(timeout=0) ochann, i, e = IO.select(@outputs, [], [], t) ←---获取等待读出的IO(b) return if ochann == nil ←---发生超时的时候 ochann.each do c = ochann.pop begin @results.push Marshal.load(c) rescue => e c.close @outputs.delete(c) end end end private :fill ←---用于内部实现的方法,因此声明为private def result fill ←---从Pool中获取数据的方法 @results.pop end end def initialize(n=2) @pool = Pool.new(self,n) ←---HashFold初始化,参数为构成池的进程数 end ←---仅创建进程池 def hash_merge(hash,k,v) if hash.key?(k) ←---Hash合并 hash[k] = self.fold(hash[k], v) else hash[k] = v end end def start(inputs) inputs.each do |input| ←---HashFold计算开始 @pool.push(input) ←---将各输入传递给Pool end hash = {} inputs.each do |input| @pool.result.each do |k,v| ←---获取结果用的Hash hash_merge(hash, k,v) ←---将结果Hash进行合并 end end hash end end图 8 运用进程池的 HashFold
和图 7 程序相比,由于增加了重复利用的代码,因此程序变得更复杂了。不过,要想象出这个程序的行为也并不难。
和图 7 程序相比,具体的区别在于并非每个输入都生成一个进程,而是实现启动一定数量的进程,对这些进程传递输入,再从中获取输出,如此反复。因此,图 7 的程序中只需要用一对 pipe,而这次的程序就需要分别用于输入和输出的两对 pipe。
此外,在并发编程中还有一点很重要,那就是不要发生阻塞。如果试图从一个还没有准备好数据的 pipe 中读取数据的话,在数据传递过来之前程序就会停止响应。这种情况被称为阻塞。
如果是非并行的程序,在数据准备好之前发生阻塞也是很正常的。不过,在并行程序中,在阻塞期间其他进程的输入也会停滞,从结果上看,完成处理所需要的时间就增加了。
因此,我们在这里用 select 来避免阻塞的发生。select 的参数是 IO 排列而成的数组,它可以返回数据已准备好的 IO 数组。select 可以监视读取、写入、异常处理 3 种数据,这次我们对读取和写入各自分别调用 select。
图 8 的 (a) 处,对位于池中进程的写入检查,我们使用了 select。select 的参数是要监视的 IO 数组,但这里我们需要检查的只是写入,因此只在第 2 个参数指定了一个 IO 数组,第 1、第 3 参数都指定了空的数组。
图 8 的 (b) 处,我们对从进程池中读出结果进行检查。select 在默认情况下,当不存在可读出的 IO 时会发生阻塞,但当读出队列中已经有的数据时我们不希望它发生阻塞。因此我们指定了一个第 4 参数,也就是超时时间。select 的第 4 参数指定一个整数时,等待时间不会超过这个最大秒数。在这次的程序中,当队列不为空时我们指定了 0,也就是立即返回的意思。
要避免发生阻塞,除了 select 之外还有其他手段,比如使用其他线程。不过,一般来说,通过 fork 创建进程和线程不推荐在一个程序中同时使用,最大的理由是,pthread 和 fork 组合起来时,实际可调用的系统调用非常有限,因此在不同的平台上很难保证它总能够正常工作。
出于这个原因,同时使用 fork 和线程的程序,可能会导致 Ruby 解释器出现不可预料的行为。例如有报告说在 Linux 下可以工作,但在 FreeBSD 下则不行,这会导致十分棘手的 bug。
那么,我们用图 8 的 HashFold 来测试一下实际的运行速度吧。和之前其他程序一样在 1.9 的相同条件下运行,结果是 user 0.72 秒,system 0.06 秒,total 10.604 秒。由于不存在生成大量进程所带来的开销,性能有了稍许提升。此外,对抖动的抵抗力应该也提高了。顺便提一句,1.8.7 下的运行时间为 25.854 秒。
小结
对于我们这些老古董程序员来说,fork、pipe、select 等都是已经再熟悉不过的多进程编程 API 了,而这些 API 甚至可以用在最新的多核架构上面,真是感到无比爽快。
不过,目前市售的一般 PC,虽说是多核,但对于一台电脑来说也就是双核或者四核,稍微贵一些的服务器可以达到 8 核,而一台电脑拥有数十个 CPU 核心的超多核(many-core)环境还尚未成为现实。HashFold 等计算模型本来的目的是为了应对信息爆炸,而以目前这种程度的 CPU 核心数量,尚无法应对信息爆炸级别的数据处理。
看来今后我们必须要更多地考虑多台计算机构成的分布式环境了。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论