关于代码的阻塞/非阻塞,异步/同步,很多新程序员没有概念。
概念:
普通的 Delphi 程序,拖几个按钮和其它控件,在按钮的 OnClick 里面写几行代码,就能实现自己想要的功能。例如:
procedure TForm1.Button1Click(Sender: TObject);
begin
Memo1.Lines.Add(Self.SendString('get' + #13));
end;
这里只有一行代码。即便这个代码非常复杂,深入进去里面包含了程序员自己写的很多代码,可能是一个复杂的类底下的复杂的方法,调用了多个函数,追踪下去,如果追踪到 Delphi 的 VCL 库的源代码里面,会发现一层一层的函数调用。对新程序员来说,追踪阅读这样的代码,会学习到很多写代码的技术。
但是,不管这里的代码有多复杂,它都是一行一行地往下执行的。必须执行完一行,才接着执行下一行。对于 Delphi 的程序来说,所有这些代码,都是执行在【主线程】里面,以【阻塞】的方式在执行。这里所谓的【阻塞】,就是执行一行代码,程序就阻塞在那里了,一直等到这行代码执行完成,程序才执行第二行代码。每行代码的执行,都是需要时间的。普通的一行代码,执行时间可能不到1毫秒,对程序员来说,可能就是瞬间完成,感觉不到【阻塞】。但是,有些代码,一行代码的执行时间可能会很长,比如,看看以下代码:
procedure TForm1.Button1Click(Sender: TObject);
var
i: Integer;
begin
i := DoSomeCal(i);
Memo1.Lines.Add(i.ToString);
end;
假设上面的代码的 DoSomeCal 是这样的:
function DoSomeCal(const i: Integer): Integer
var
j: Integer;
begin
while j < 99999 do
begin
Sleep(1000);
j := j + Random(i);
end;
Result := j;
end;
上面这个 DoSomeCal 函数,可能执行几秒,可能执行几分钟。那么,对于 Button1Click 里面的代码来说,执行 DoSomeCal 就卡住了,一直等到几分钟以后这个函数完成才会执行第二行 Memo1.Lines.Add 这句。
这就是所谓的 【阻塞】。那么,对于我们的程序来说,用户点了那个 Button1 程序整个就卡住了,界面冻结,没有反应。用户会以为死机了。一直等了几分钟,程序突然又活过来了。
这种阻塞,在消耗时间的大计算时会出现。还有一种常见的场景:网络访问。因为网络速度不可控,网速慢时,一个网络访问可能好久不会有结果,如果采用阻塞式的编码方式,程序卡死了,用户的感觉会很不好。
同步:这种阻塞式的编程,又叫【同步】。这里所谓的同步,是指代码一行一行执行,不会乱序。不会执行完第四行代码了,第三行还没有被执行。
主线程:对于 Delphi 程序来说,用户点击 Button1 发起的 Button1Click 事件里面写的代码,是被主线程执行的。对于不写多线程代码的程序员来说,所有的代码都是被主线程执行的。而一个主线程,同一时间只能执行一行代码。所以,所有的代码都是被一行一行地往下执行的,是【阻塞】模式。也就是【同步】模式。
非阻塞:对于上面那种程序可能会冻结几秒甚至几分钟的代码,为了让用户感觉好一点,改善所谓的用户体验,我们可以把耗时比较长的代码,单独放到一个线程里面去执行。这样,就不会阻塞主线程,也就不会让程序界面被冻结变成毫无反应对鼠标和键盘的操作都没有反应。假设上述 DoSomeCal 函数被放进一个线程去执行,那么,Button1Click 被触发的时候,主线程调用这句话,没等这句话内部的代码执行完就马上执行下面 Memo1.Lines.Add 这行代码了。这种编程模式,就叫【非阻塞】,也叫【异步】。这里的【异步】是相对于上面说的【同步】来说的。
多说一句:在上述【异步】模式下,Memo1.Lines.Add(i.ToString) 输出给用户的 i 的数字不是 DoSomeCal 函数最后的计算结果。所以,【非阻塞】模式或者【异步】编程模式,代码的写法是不同的。具体如何写,这里不展开。
---------------------------------------------------
对应本文的标题,如何异步变同步。
Delphi 盒子论坛有人发问:
【有没有支持阻塞式的Telnet控件?Indy 的是异步,不知道怎么用同步方式接收数据。】
于是我打开 Delphi,拖一个 IdTelnet1 到 Form1 上面,研究了一下这个控件该怎么使用。以前没有用过这个控件,把它放到界面上以后,发现它不是采用的 Indy TCP 惯用的【同步】式编程模式,而是异步式。
这个 IdTelnet 有个方法叫做:SendString,当向一个服务器发送字符串以后,服务器返回的字符串不是下一行代码,而是在它的 OnDataAvailable 事件里面把服务器端返回的字符串送出来。因此,写程序的时候不能一行一行往下写,不是【同步】的。程序这样写:
procedure TForm1.IdTelnet1DataAvailable(Sender: TIdTelnet;
const Buffer: TIdBytes);
begin
Memo1.Lines.Add(StringOf(TBytes(Buffer)));
end;
function TForm1.SendString(const Cmd: string): string;
begin
IdTelnet1.Host := '192.168.1.254';
IdTelnet1.Port := 80;
if not IdTelnet1.Connected then
IdTelnet1.Connect;
IdTelnet1.SendString(Cmd);
end;
procedure TForm1.Button1Click(Sender: TObject);
begin
Self.SendString('get' + #13);
end;
上述代码,Button1Click 被用户点击触发,调用 SendString 发送字符串。我发送的是一个测试用的 IIS Web Server。所以发送 get 加上回车。IIS 会将默认的页面返回给我。如果 Memo1 里面显示 IIS 返回的页面 HTML 代码,则说明调用成功。
在 SendString 函数里面,调用 IdTelnet1.SendString 函数发送字符串给服务器,程序结束。本来,接下来应该就是读服务器的返回字符串然后写入 Memo1,但是这里,IdTelnet 并没有这样操作,而是提供了一个事件:OnDataAvailable ,当有数据从服务器返回的时候,这个事件被触发,我们在这个事件里面,把拿到的数据写入 Memo1 显示出来。
上述编程模式,就是所谓的【异步】编程。
那如何改为同步呢?基于面向对象的原理,我不想去改造 IdTelnet 的源代码,而是想继承它,或者封装它。
首先,我们要让 Button1Click 在发送完字符串后被阻塞,一直等到服务器返回的数据从网络上全部收到后,才解除阻塞,才继续往下执行;
其次,因为 Button1Click 是被主线程执行的,这时候主线程已经被阻塞,我们要保证,解除阻塞的代码,是被其它线程执行,而不是被主线程执行。否则,主线程被阻塞停下来了,解除阻塞的代码就永远不会被执行到,也就永远不会解除阻塞。
那么,这个 OnDataAvailable 事件里面的代码,是被主线程执行的,还是被其它线程执行的?仔细看 TIdTelnet 的属性,在属性面板里面,有一个叫做:ThreadedEvent 的属性,可以设置为 True / False;如果你去看 TIdTelnet 的源代码,你就会知道这个属性设置为 True 或者 False 会是什么情况。不过,我没看源代码,既然它叫 ThreadedEvent,里面有个 Thread,那么,多半就是说这个事件是线程的还是非线程的,如果是非线程的(False)说明这个事件是被主线程调用的,如果是线程的(True)说明这个事件是被其它线程调用的。
既然这里有其它线程调用 OnDataAvailable 那么事情就简单了,我先把改造为【同步】的代码贴出来:
type
TForm1 = class(TForm)
IdTelnet1: TIdTelnet;
Memo1: TMemo;
Button1: TButton;
procedure IdTelnet1DataAvailable(Sender: TIdTelnet; const Buffer: TIdBytes);
procedure Button1Click(Sender: TObject);
procedure FormCreate(Sender: TObject);
private
{ Private declarations }
FEvent: TEvent;
FBuffer: TBytes;
function SendString(const Cmd: string): string;
public
{ Public declarations }
end;
var
Form1: TForm1;
implementation
{$R *.dfm}
procedure TForm1.Button1Click(Sender: TObject);
begin
{
IdTelnet1.Host := '192.168.1.254';
IdTelnet1.Port := 80;
IdTelnet1.Connect;
IdTelnet1.SendString('get' + #13);
}
Memo1.Lines.Add(Self.SendString('get' + #13));
end;
procedure TForm1.FormCreate(Sender: TObject);
begin
FEvent := TEvent.Create;
end;
procedure TForm1.IdTelnet1DataAvailable(Sender: TIdTelnet;
const Buffer: TIdBytes);
begin
//Memo1.Lines.Add(StringOf(TBytes(Buffer)));
SetLength(FBuffer, Length(Buffer));
Move(Buffer[0], FBuffer[0], Length(Buffer));
FEvent.SetEvent;
end;
function TForm1.SendString(const Cmd: string): string;
begin
IdTelnet1.Host := '192.168.1.254';
IdTelnet1.Port := 80;
if not IdTelnet1.Connected then
IdTelnet1.Connect;
IdTelnet1.SendString(Cmd);
if FEvent.WaitFor(2000) = TWaitResult.wrSignaled then
begin
Result := StringOf(Self.FBuffer);
end
else raise Exception.Create('Telent 超时!');
end;
end.
代码解释:
1. 首先,我在这里增加了一个全局的 FEvent,这个玩意就是用来阻塞代码的。当你调用 FEvent.WaitFor ,代码就停在这一行了,被阻塞。直到阻塞被解除。如何被接触呢?就是另外一个线程去执行 FEvent.SetEvent,才会解除。这里你可以理解为一个人在干活,干到一半的时候碰到一个命令:暂停,然后这个人就停下来了,睡着了;然后另外一个人在适当的时候发命令:重新开始;前面那个停下来的人,重新活过来,把手上的活接着往下干。
2. 在 SendString 函数里面,调用完 IdTelnet1.SendString 以后,就调用 FEvent.WaifFor 阻塞;阻塞解除后,把来自网络的数据 FBuffer 变成字符串返回。
3. OnDataAvailable 事件是收到来自网络服务器的数据,在这里,先把数据放进全局变量 FBuffer,然后 FEvent.SetEvent 解除阻塞;
4. SendString 函数里面,主线程停在 FEvent.WaitFor 被接触阻塞,继续执行下一句:Result := StringOf(Self.FBuffer) 返回。
5. Button1 的 OnClick 里面,用户点击按钮,调用 SendString 函数,对用户来说,就是点击按钮发送命令,获得服务器返回的字符串。放进 Memo1 里面显示出来。这里就是一个【同步】的操作。
6. 上述玩法,要注意的是,一定要把 IdTelnet1 的 ThreadedEvent 属性设置为 True,使得 OnDataAvailable 事件是被 IdTelnet1 内部的一个线程调用的。
6.1. 如果不修改上述属性,默认是 False,则因为主线程被 FEvent.WaitFor 阻塞,不可能去执行 OnDataAvailable,也就不可能解除阻塞,最后的结果是超时。
6.2. FEvent.WaitFor(2000) 是指超时 2000 后,自动接触阻塞,代码继续往下执行。如果不设置超时值,程序会永远阻塞,彻底死掉。如果设置超时值,则没有收到来自网络的数据,没有触发解除阻塞的代码,程序在 2 秒后也会解除阻塞往下执行。因此,如果你设置那个 ThreadedEvent 为 False,你可以发现用户点了 Button1 以后,等 2 秒,弹出异常:超时。
-----------------------------------------
到这里,标题的问题已经解决了。但有时候,我们还有另外一个需求:如何把同步的代码变异步?
为什么有这个需求?因为,有些代码执行时间比较长,我不希望当前的代码停在这里等待它完成后才能继续往下走。也就是本文一开始的代码例子,可能需要改为【非阻塞】的【异步】模式。怎么改?下一篇文章说吧。今天还有其它事情要忙。