http://blog.sina.com.cn/s/blog_5e0d222e0100kvqq.html
在总结进程通讯的问题时,我考虑再三。似乎逃离不了一个概念。同步与异步。因此,暂且先讨论一下进程的同步与异步。
概念
如前面总结所述,进程的概念,一定存在于多任务分时操作系统中。当然这也不是非常准确。因为如果多个CPU上同时运行不同的进程,则似乎不存在分时的问题。因此这里修正一下,假设,我们讨论的所有问题,均是在一个CPU上进行。此时,还是需要分时来完成多个进程的各自工作。同时,这种微观上分时完成不同工作的操作机制从宏观上看,每个工作又可等效于同时进行。
由于进程的概念导致,独立讨论一个进程是毫无意义的。而多个进程在工作时,如果这些进程之间存在一定联系(当然可能毫无联系),则此时会有两种情况。这就是本文要讨论的。同步和异步。
在我初学阶段,
一直以为同步是同时进行的。这是个错误的概念。曾经看到有人举例,打电话是同步,相对于发消息,我觉得似乎不妥。打电话,如果你说你的,我说我的,则就不是同步,是标准的异步模式,而发消息,比如短信,一问一答,OK,这是标准的同步模式。有人说,阻塞是同步模式,非阻塞是异步模式,这样的解释似乎有点问题。同步不一定需要阻塞。自然非阻塞也不一定是异步。
那么,谈论进程之间的同步和异步的最根本的区别是什么呢?我用个文绉绉的描述如下:
当一个进程A存在一个必须执行的操作点,同时该操作点,与另一个进程B的某个操作点存在因果时序关系,则A相对B为同步。反之为异步。
这里需要注意几点,在其他讨论同步和异步问题中所没有或完全不一的概念:
1、
A相对B同步,未必B相对A同步。
最明显的例子是,客户段请求和对反馈的处理是相对于服务端同步的。在服务段接受客户段信息后,到服务段数据没有完全给予客户端之前,这段时间客户端需要等待,也就是阻塞,以同服务器端运行同步。而服务器端并不存在这个问题。如果这个客户端不给服务器发送请求,服务器会转向响应其他客户端的请求。则服务器端相对客户端并不是同步的。
之所以出现这种不对称的更本原因,是
A,B进程工作目标,或工作性质决定的。
2、
AB两个进程之间必须存在操作点之间的因果关系,才叫同步。如果说两个进程抢打印机,则不叫同步。虽然可能存在某个操作点上的相互影响。虽然他们之间可能出现阻塞。但和同步异步没联系。
这一条,实际上也是说扩展说明了。讨论到同步异步,均是指:AB协同完成一个整体任务,缺一不可的情况下。如果两个毫无联系,毫无因果关系,老死不相往来的进程,则不存在同步和异步的讨论。虽然你可以说他们是绝对异步。但没有必要通过同步和异步的分析方法来处理和约束这两个进程的设计。
3、
A相对B同步,则一定存在一个严格按照{ 请求-->等待-->接受 }的时序过程进行。
这种工作在软件设计时是显式存在的(即你在代码中可以看到)。也就是说,存在因果关系的阻塞才是同步。
例如,机器A向机器B传输数据。采用对信息分块,并打包,包前缀含有同步信息的时候,或许有人说。此时不需要等待,因为存在同步头。这种观点本身没有错。如果仅放在传输之段执行时间内来看。但考虑一下传输前的工作。机器A可以在任何情况下,直接传递信息吗?不需要建立信道(通过握手,或查询缓冲区是否清空的各种方式)?考虑一下握手动作,A发送请求REQ,B接受,返回ACK。这就是请求,等待,接受,而采用缓冲方式,A要查询缓冲区是否空(即上一次信息是否被B全部获取),这实质是个等待过程,而发现缓冲区为空,这实质是通过缓冲区的情况,来实现机器B向机器A发送接受信息。
4、
同步和异步都是相对的。在不同的尺度下,可能产生变化。而同步代码的设计和异步代码的设计存在本质差异,因此,分割尺度形成模块化设计,才是程序设计的大学问。至于采用什么函数,什么调用方式,这本身不是一个程序员的价值所在。你能背下所有的C的标准库的函数及参量不代表你能成为一个合格的程序员。
分析同步和异步,既要考虑任务切割后,每个子部分的工作特性,又要考虑每个子部分及整体的运行参数和目标要求。
我们做个例子来分析同步和异步
进程A完成外部键盘读入,并存在一个缓冲区。而对读入的字符的分析,由进程B执行。当缓冲区满时,一次性传递给B,由B处理。虽然你按一个键可以在任意时间发生,但A的缓冲区的内容必须等满后才能传递给B,而同时,只有B不忙时,才能接收数据。如果A的缓冲区满了。必须等待B接收完数据,才能继续运行。因此从整体上来看,A相对B而言是同步的。
可以反证,假设A 相对B是异步的,则A的运行和B没有关系。但是,A在缓冲区满时,无法继续运行,因此假设不成立。
但B相对A是同步吗?
假设B的工作如下:
如果键盘请求存在,则接收键盘信息处理(这和上面的工作是一致的),但如果键盘没有信息传递,则检测鼠标是否存在信息传入。此时,
B进程,并不会因为A的数据没到位而停止工作。也就是说。无论A是什么情况,B还有很多其他事情要做。同时B该做什么做什么,等有空了,在看是否A有数据需要处理。
因此,B的工作,不会因为A的状态,而导致阻塞。
上面的讨论,说明了。同步不是相互依赖存在的。
那么将上面的例子修改一下,假设A的缓冲区只要有数据,就传递给B操作。同时假设缓冲区足够大,B的处理时间足够快。那么此时,A相对B又变成了异步。为什么呢?
因为,B在处理A缓冲区的内容的时间足够快,以至于A不会因为外部键盘输入把缓冲区填满导致A的停滞。可以说,A的工作和B没有关系。B的工作和A也没有关系。
可能有人会说,上面例子,一会是同步,一会是异步,其根本原因在于,A缓冲区是否填满才向B传递数据。如果这样理解,则是不完备的。如果这么去设计工程,必然会导致一个潜在风险(假设当作异步模式设计,但A被填满同时阻塞,由于认为是异步模式,所以A采用丢后不管的方式,而阻塞下,A会漏掉输入信息,导致系统出错)。
实际上,上面的同步和异步的差异,还存在于一个假设,即B处理的速度足够快,A的缓冲区足够大。这种情况下,才能完全认为A,B是异步模式。当你发现你的工程的使用目标不能满足上面的假设(B处理速度足够快),则一定要小心处理,当缓冲溢出的代码。按照同步设计的思想进行处理。
讨论到这里,你也可以发现,
绝对同步是存在的,绝对异步是不存在的。除非两个任务丝毫没有联系。但不代表异步工作毫无意义。
UDP与TCP就可以看作,在针对消息传递这个工作上,前者是异步模式,后者是同步模式。很多情况下,无法做到完全同步(这需要硬件支持),也有些情况下,不需要做到完全同步(例如广播消息,没有收到的信息,并不是重要的,不会对系统产成影响)
下面讨论一下同步和异步在设计,或规划中的问题。
首先,我们假设用模块来替代进程。这样可以更好的讨论问题。那么模块的划分,直接影响到属于同步还是异步。
例如,上面的例子,假设A进程实际上内部有两个模块,一个模块是读取键盘信号,转换成字符。而另一个是管理缓冲区,并向B发送消息。
整体看进程A相对B是同步模块。但内部读取键盘信号的工作和缓冲区管理则是异步的。那么是否可以将两个模块合并,并形成一个程序代码依次执行呢?假设硬件不存在缓冲,通过键盘中断获取消息。这样完全可以。但是这样会使得代码逻辑混乱。因为,可能处理缓冲区的工作中,被键盘中断信息打断。这样会有一个可能的错误。
我们假设BUF足够大,传递数据足够快,同时我们在A模块中,采用双BUF方式,确保任意按键被记录。则通常存在如下代码
if (buf.size >=MAX_BUF_SIZE){//当缓冲区满
switch_buf(buf,send_buf);//将接受缓冲区和写出缓冲区进行切换。
buf.size =0;//A的缓冲区清0
send_da
ta(send_buf);//向B发送数据,这里面假设最简单的传送方式,B存在个对等的缓冲区,而只是个简单的COPY工作。同时不考虑B的缓冲区的数据冲突问题。
}
如果中断发生在switch_buf(buf,send_buf);之后,buf.size = 0;之前,很显然,通过
buf.da
ta[buf.size ] = c;的方式记录按键会出错。当然上述代码可以修改成如下模式
if (buf.size >=MAX_BUF_SIZE){
switch_buf(buf,send_buf);
send_da
ta(send_buf);
send_buf.size = 0;
}
那么如果中断发生在if (buf.size >= ...)之后,switch_buf之前,或switch_buf内部,如何处理呢?通常系统设计,为了杜绝这种问题,采用了原子操作,其实就是关中断,处理些事情,再开中断。保证这些事情在处理时中断禁止执行。则上面代码得修改如下
cli();//关中断
if (buf.size >=MAX_BUF_SIZE){
switch_buf(buf,send_buf);
sti();//开中断
send_da
ta(send_buf);
send_buf.size = 0;
}else{
sti();//开中断
}
这样的写法,保证了模块A的正确性。但是这样的系统设计有些不妥(得承认设计本身没有问题,执行也没有问题),不妥在哪?cli和sti因为什么而导致要关闭?写代码的人和系统规划作设计书的人可能知道,但后者很难详细描述这个细节。看代码的人,则可能一头雾水。甚至在添加,调整代码时,将这个正确设计给改错了。那么不妨如下设计。
if (buf.size >=MAX_BUF_SIZE){
g_switch =1;
switch_buf(buf,send_buf);
if (back_c){
buf.da
ta[buf.size ] = back_c;back_c =0;
}
g_switch = 0;
send_da
ta(send_buf);
send_buf.size = 0;
}
另一段代码中断响应函数如下:
if(g_switch_buf){
back_c =input_key;//input_key由中断代码进入
}else{
buf.da
ta[buf.size ] = input_key;
}
这里我们用了一个back_c的缓冲,此时不需要关中断。只是通过了g_switch这个全局变量来保证中断响应中的不同执行方式。
可能有人认为我这里是画蛇添足,首先,我需要明确一点的是,如果键盘输入产生的间隔时间小于switch_buf的执行时间。无论是新方法,还是关中断,都会导致数据遗失,我说了,假设键盘没有任何缓冲。而在键盘输入产生的间隔时间大于switch_buf执行时间时,上述两种方法均可以正确记录数据。但后一种方法显然保证了键盘数据获取的异步性。而前一种方法,由于存在关中断,开中断,实际上是采用了同步方式。这种特殊情况下的同步方式,会导致对模块的理解及设计上的难度增加。而后一种方式,采用了通过增加缓冲,使得可以通过异步来延后实现同步机制。由此,让A模块中两个任务更为明确分割更为合理。
也许有人会说,何必呢。cli,sti我就喜欢用,也没有错。恩。可以这么说。但考虑cli是全局的关中断。在模块划分时,如果A模块存在cli,B模块存在cli,且两者之间没有任何联系,难道这不混乱吗?如果模块C是处理鼠标信息获取,它就会很郁闷,为什么我总是偶尔会延迟?查来查去。哦,A模块在关中断。而这种相互影响,在系统设计时是没有指出也没有要求的。当一个系统规模逐渐变大时,这里潜在的不爽往往会导致非常的不爽。甚至导致联系的别的模块的cli导致键盘无法被响应到。
上面这个例子说明一个问题:
同样的工作,有时即可以用同步实现,也可以用异步实现。同步和异步的实现方法的决定,是依赖于系统规划的方法,而不是程序代码书写的方法。模块划分好坏,在不存在多进程,多任务的情况下,问题仅局限在代码的可扩展,可维护上。但在考虑进程及同步异步时,则问题会变的复杂。
那么如何划分模块,并指定采用同步或异步方案呢?说实话,这方面的教科书很少。通常LINUX内核的书籍中会讨论些进程模块之间的同步异步问题。我个人也无法完备的回答这个问题。但从个人经验总结来看,基本有以下几个方面:
1、假设已经框定一个系统,或一个模块的设计任务。则罗列出所有的独立任务分割成子模块。所谓独立任务即,划分成子模块时,出了数据传递以外,不存在当前子模块和其他模块之间的联系。同时,该子模块,对自身的数据存在完备的处理。并将个个子模块用图的方式描绘出来,而节点表示模块,方向线表示数据传递。
例如,系统要求,处理键盘,鼠标的输入数据,并计算同时在屏幕上显示。如果是键盘,则在最下方打印,如果是鼠标,则在屏幕的对应位置画出鼠标图标。整个系统,是存在输入,处理,输出的。这是一个完整的系统。如果没有输入,谈不上处理,如果没有输出,则处理毫无意义。如果没有处理,输入和输出没有任何变化(哪怕是存储地址的改变,这也是处理阿),那么这个系统没有任何意义。
则该系统存在以下几个任务
a、读入键盘数据
b、读入鼠标数据
c、获得键盘打包数据,比如一行,或一个单词的数据
d、计算鼠标绝对坐标,及在屏幕上的相对坐标
e、在指定位置打印键盘数据
f、在指定位置画出鼠标图标
为什么不能将a,b合并成一个子模块。因为合并后,可能键盘不工作,而鼠标工作,那么独立任务在键盘不工作时,无法完成。因此要分割为a,b两个部分。
为什么不能将d分割成两个模块。如计算绝对坐标后,传递给另一个模块,再计算相对坐标,显然这样是可以的。但对于坐标的处理,这样分割后,无论那个模块都不是完备的。此时会出现一个问题。假设上面两个模块分成d1,d2,那么如果系统设计后期,突发奇想,通过键盘输入的坐标,来修改屏幕的相对坐标的0点的位置。此时用d3模块来描述,则会出现即便没有鼠标输入时,也需要通过d3,和d2来修正当前的鼠标图标位置。那么就存在d1-->d2,d3-->d2的两套工作。同时他们之间没有时序联系。这样会导致一个问题,d1传递给d2后,d2不能接收d3数据。直到d2处理完毕。这无疑增加了系统复杂度。注意这里讨论同步和异步分割模块的问题,并不是代码分割成子函数,复用调用的问题。
因此说,对于输入的完整数据的处理,如果在模块内是完备的,则不需要再进行子模块的划分。那样会导致系统的复杂。
2、输入数据和输出数据的频率。当输入频率高于输出频率时,则要采用输入异步,输出同步(相对输出方则也为同步)。即如果键盘输入间隔非常短,且传输数据时间非常长(比如网络传递),则输入要即时响应,输出通过大BUF来同步发送。而反之,如果输入频率足够低,而输出频率(即输出所需时间的倒数)足够高,则可以采用同步输入。
3、如果存在多个输入,则不能采用输入同步。因为输入同步,必然导致该模块的实现混乱。即同时有数据输入时,该处理哪个接口。如果这是不可避免的,则需要分割模块。采用缓冲方式,将多个同步输入的数据,通过新的子模块收集起来传递给实际处理的模块。
4、如果模块之间是单向依次数据传递且数据接受模块仅接收某一模块的数据,则必然是接受模块必然是同步模块。例如文件模块打开文件,视频解码模块解码,则是同步。但视频解码模块和显示模块则不是同步。显示模块还接收其他内容。此时如果显示模块错误的使用了同步方式,则可能会导致系统出错。
5、如果模块之间,数据流向成环状,则该环上,必然是同步模式。例如如下
A --> b ----> c----> d --- > b
|--------> e
即,A是最初输入,e是最终输出,但b,c,d构成了,数据环,则b,c,d之间,必然是同步的。当然d,e之间不一定需要同步。a,b之间也不一定需要同步。举个简单例子。
假设有4个人。a,b,c,d。a 通过手机告诉b 消息。b 不通过对讲机告诉c消息,c通过手机告诉d消息。那么怎么工作呢?
a 用手机:b 今天有雨,
b 用对讲机 :c 今天有雨 OVER
c 用对讲机 : 确认,今天有雨 OVER
c 用手机: d 今天有雨
当然,b在向c发消息时,a 可以向b说,后天有雾
但a,b之间不需要同步。而b,c之间需要同步。只有c确认后,b才能告诉c,后天有雾,而当然c在向b确认时, a向b说后天有雾,也是可以的。
6、任何异步方式,多个模块整体的执行效率,以最慢的模块为准。如果输入速度大于该速度,则会导致系统崩溃(或系统无法承载任务)而同步方法,不存在这个问题。但会导致任何子模块的执行效率等于最慢的那个模块的效率。同步同步,不是同时进行,而是同节拍进行。
7、模块划分,要防止出现两个模块之间,即有同步,又有异步模式。
上述一直讨论进程间数据传递导致的同步问题。当然有时也会存在资源占用导致的同步问题。例如比较经典的例子就是打印机冲突。任意时间,打印机可能在工作或不工作。如果认为打印机一直在等待自己,而采用同步方式发送数据打印,则会导致自身阻塞。类似这样的问题很多。打印机的例子很简单。但陈诉了一个事实。由于资源的共享,会导致同步的异常阻塞。或许有人说,这简单,改异步就可以。发送个文件,或查询一下,决定是否打印,一切就OK了。是的,确实如此。当上述问题变的复杂时,通常会导致一个问题,这个代码,刚开始工作好好的,则后面死了?内存没有溢出啊。通常出现这种问题,就是由于资源的占用导致的死锁。而这种占用的情况,是动态发生的。这类错误是非常难以DEBUG的。一般情况下,是需要在设计之初进行论证。并给出异常时的解决方案。例如下面的例子
进程的教科书上,都会讲一个例子,比如存在A,B两个进程。A需要等待B的处理结果进行处理。而此时B又需要A释放资源才能进行工作。而A在没有B的处理结果时,不能释放资源。则系统就会被锁死。举简单例子。
A进程,负责读取一个文件,此时打开文件后,锁住文件,禁止别人修改。而A读了一段文本后,让B处理,而B需要处理完写入该文件的操作。呵呵,此时A锁住了它。你怎么写呢?A锁住他也有他的道理。比如是个查找#define<filename>的工作。并将<filename>的文本内容加入到该位置,并替换掉#define<filename>这一行。如果A不锁住,而让B任意修改,则A的工作没有办法完成。A刚处理这行,还没有结束,B就修改了这行,则A需要重新处理。
在这种情况下,如果在设计之初,简单的认为,我的代码里有两个进程,1个读,一个写。而且想当然的认为这两个文件不能是同一个文件,而没有在系统设计规划中明确,则编程时也没有添加
if (strcmp(input_filename ,output_filename) ==0){
error;
}
的处理,则系统设计就会有问题。别人用的里的代码时,谁会去考虑你代码里这样的设计约束呢?
更何况有些资源,是无法如文件这样,可以通过临时文件来模拟扩充的。遇到这种问题,需要分析数据流向,并合理划分模块。将同步环提出来,并分析同步环中是否存在一个资源两用的情况。如果存在,则需要增加辅助代码来实现解锁或同步约束。
上面讨论的是个简单的例子,再说个复杂的。假设我们存在一个内存堆。或者缓冲池。每个进程需要获取这个缓冲池的一定的空间来执行自身操作。如果存在10个A同时执行,OK,没有问题。此时,第1号进程,需要创立个新进程,才能继续完成任务,但是当加到第11个时,内存堆不够,如果此时导致1号进程无法执行下去。则1号进程就在等待。而2号进程需要等待1的计算结果,才能继续。并释放自己。假设3到10号进程和他们没有关系。并假设,1到11号进程所需要的空间均相同,也就是说,从3到10之间,任意删除一个,第11号进程就可以执行。那么你肯定可以发现此处我所说的错误。但是这个错误只有在3到10号进程全部在执行,及缓冲区没有空间情况下,才会出现。而如果只有,1到5个进程在运行,而此时1号进程为了完成工作,需要申请新进程,则是可以运行的。这种运行时的资源冲突导致的死锁错误,在多进程设计中,屡见不鲜。这个问题的本质,和非进程编程中,滥用malloc是一样的。或几乎等于无限的递归函数调用。
此处,岔开话题,讨论下普通编程的心得。曾经,很早很早以前,听到一个传闻,说印度人编程,不喜欢用动态存储。喜欢开静态数组。我觉得这是个非常好的习惯。即,我这个程序的生命周期内所需要的资源,进行最大化获取,如果可行,我就执行,如果不可行,我另可不执行。而不是动不动,在函数里面执行malloc。malloc有人说OS会帮你清理掉没有free的空间。但OS却很难管理符合安全性标准的指针变动问题。而很多数据跑飞的原因是指针飞掉了。指针飞掉的根本原因,还是在于没有良好的编程习惯引起的编程逻辑混乱所导致的。如果不自我约束malloc的工作,则很难想象这样的程序员能很好的管理自己的指针。有人会说,在嵌入式系统里,内存紧张,静态数组太大,并不是个灵活的设计方法。那么我可以很不客气的批评这种言论是幼稚的。越是资源紧张的环境,越是要仔细分析每个BUF,及其周期,如果这些周期你都能分析好了。难道还需要用动态数组吗?内存不是时间,或X人的XX。挤挤就会有的。你可以去尝试考虑内存在不同代码执行周期内复用的可能性,也不能想当然的认为malloc会为你提供一切。
因此,进程设计时,包含同步和异步的设计考虑。一定要分析清楚,当前进程在生命周期内,所需要的全部资源,包括那些,因为同步于其他进程所包含的资源。其根本原因在就在于同步的特性,你必须要同节拍进行。而当等待节拍时,发现你的同步对象无法完成,导致自身死锁,则需要重新规划你的设计。是采用异步替代,还是通过其他的方法实现。而直到我在用VHDL做数字电路的模块设计时,才真正理解同步和异步的概念。
所有评论(0)