Delphi、C++ Builder多线程程序编码调试的一点经验谈

多线程程序的调试对于程序员来说,是心中永远的痛,调试起来痛苦程序不亚于孕妇难产。本篇文章试图将我自己的一些小经验与大家分享,希望对大家会有所帮助。我在这里为大家列出自己总结的多线程编程的三个注意事项:

一、你的代码不是执行是可能随时被抢断的,所以第一个要注意的就是:多线程程序编写时,永远不要假设代码的执行是连续的。

也就是说执行完上一句代码,如果你认为计算机肯定会执行下一句代码的观点是危险的。因为无论Windows还是Linux,都不是早期的协同式多任务模式了,当你的CPU时间片被耗尽或者有优先级更高的线程产生时,你的时间片就会被切出去,然后执行另外的线程的代码,这也就是为什么多线程程序为了保证顺序执行必需使用锁来阻止其它线程同时存取某些数据成员的原因。

二、由第一条推论可知:如果你创建了多个线程,先创建的线程并不肯定会先执行,后创建的线程未必后执行。

同上面的理由,你前面创建的线程的时间片是可以被后面的线程抢断的,因此,操作系统的调度可能会让后面的线程反而先开始执行,甚至先于前面创建的线程执行完。

三、除非你100%确认安全,不要试图直接访问全局或者其它线程的变量。

乐观的认为数据是可以随意访问的观点是极度危险的,我们尤其要注意Delphi/C++Builder中,各种VCL组件除非明确标记线程安全的,否则我们都可以认为是非线程安全的。

由第一条我们来推论几个过程:

1、你试图写入一个变量,而在你真正写入之前,可能很不幸,你当前线程由于各种原因,时间片被切出去到其它线程,而其它线程恰恰该死的又删除变量,结果当时间片再切回到你的线程执行写入操作时,问题发生了。

2、你试图写入一块内存,而你正写入一半时,同样不幸的事情发生了,幸运的事,其它线程没有删除这个变量,只是试图读取它的内容,结果就是其它线程得到了一个错误的内容。

3、你为你的变量赋值,本身你可能认为它是原子的,就一个最简单的加1,但实际上,你如果切换到CPU调试窗口看汇编代码就会看到,你最简单的一句话,对于计算机来说,意味着很多条指令,在这些指令执行的任一个点都可能被抢占出去。如果多个线程如果同时为一个变量改变值,你就会发现很花花的结果:比如你一个整数,原始值是100,你在你的线程中加1,然后赋给一个变量,然后想当然的认为后者的值是101,而实际上这有可能成立,也可能变成了102,在你百思不得其解时,不防看看是不是有线程外赋值的情况。

由此,我们可以得到一个设计多线程程序的基本规则:如果逻辑上不能保证被抢占后,变量的值仍然是可靠的,那么,就用系统提升的各种锁和同步方法来保证这前一点。

上面说了那么多,我们接下来就有一个疑问:为什么有时候它在我机器上执行是正常呢?实际上,这方面的原因有很多种,如上面的第1个推论可能是因为程序自己的内存分配器缓存了你释放的内存,这样子,你访问时,对于操作系统来说,这块内存仍然属于你这个进程的,是可以访问的。但这是不可靠的,依赖这一点是危险且不可靠的。

最后,不幸发生了问题了,我们现在看看多线程程序该如何找出问题所在:

情况1:程序抛出异常,IDE直接定位到出错的地方了,我想你该庆幸雨点子大到砸到你头上了!这种情况,一般你前后读一下代码,理一个逻辑,就可以得出结论错在那儿了。

情况2:程序抛出异常,IDE无法定位到出错的地方或定位的地方不对。很不幸,这种情况下只能有逐渐缩小范围的方式排除问题,二分法在这里是一个很好的选择,你可以一段一段的代码尝试捕获异常,然后一半一半的找,一般情况下也还是相对容易找到异常出现的位置,进一步准确跟踪的。

情况3:程序发生死锁,程序没响应了。这种情况下,实际上很好调试的,下面是XE6的一些方法及步骤:

(1)、选择IDE中的暂停执行按钮,暂停程序的执行。

thread_pause

(2)、切换到ID的线程查找窗口,如果找不到,进入View菜单下的Debug Windows->Threads就可以打开,如果仍不能打开,重启IDE或者是尝试切换下窗口布局看看。

thread_paused

(3)、双击每个线程,查看线程的堆栈状态(找不到时进入方式:View->Debug Windows->Call Stacks):

thread_waitfor

(4)、好了,以上面的线程状态为例,我们可以它在调用THandleObject.WaitFor,然后调了操作系统的ZwWaitForMultipleObjects进行等待状态,这就找到它锁死的原因了。那么,接下来就是找为啥这里锁死了,再切换到其它线程去看看:

thread_waitfor2

我们看到它调用了Sleep,陷入了死等状态,通过简单的分析就可以知道它引起了线程8220的假死,我们就处理好它就可以了,问题就能得到解决了。

四、我如何保证多线程中数据访问是安全的?

(1)、如果是Delphi的VCL./FMX组件,几乎可以肯定,其不是线程安全的。所以你在访问时,要对其进行一定的区别对待:

  • 如果是窗体上的可视组件,那么,可以100%肯定,你必需将对其处理的代码放到主线程中执行。
  • 如果是非可视的组件或变量,那么你就必需保证访问前,通过临界等方式进行锁定,防止其它线程同时读写。注意一点,Delphi的多读单写锁实现有问题,所以尽量不要使用它。

(2)、其它类型的全局变量是否是线程安全的,需要你对其进行详细的了解,如果是线程安全的,那么直接访问就没有问题,否则就要使用临界等方式同步访问。

五、实现多线程中数据安全访问的方法有那些?

(1)、将对非线程变量的访问限制到一个线程中,一般是限制到主线程中。

在Windows下,你可以用PostMessage或SendMessage来直接投寄消息给相应的窗口,然后在相应的窗口中响应消息进行处理。如果你使用QWorker,可以直接投寄一个主线程中执行的作业,在该作业中处理,而对于2010后的Delphi/C++ Builder,你也可以使用匿名函数直接处理,看起来更直观些。

首先看下面演示的例子:

//声明部分
TForm1=class(TForm)
...
  procedure DoSyncAccess(var AMsg:TMessage);message WM_APP;
...
end;
//实现部分
procedure TForm1.DoSyncAccess(var AMsg:TMessage);
var
  S:PString;
begin
S:=PString(AMsg.WParam)^;
Memo1.Lines.Add(S^);
Dispose(S);
end;
//线程访问部分-使用PostMessage
...
var
   S:PString;
begin
New(S);
S^:='Hello,world';
PostMessage(Form1.Handle,WM_APP,WParam(S),0);
...

如果换成SendMessage,由于SendMessage会等待消息处理完成才返回,上面的S就可以直接使用栈上的变量,内存释放会简单些,但使用SendMessage要注意一点,千万不要整成隐式的递归循环,否则会死掉。

下面是QWorker的匿名函数版演示代码:

...
var
  S:PString;
begin
New(S);
S^:='Hello,world';
Workers.Post(
  procedure (AJob:PQJob)
  begin
  Memo1.Lines.Add(PString(AJob.Data)^);
  Dispose(PString(AJob.Data));
  end,
  S,
  True);
...

将其中的匿名函数换成非匿名的版本即是普通的函数版本,代码不再重复。如果要等待作业完成,则用TQJobGroup来等待作业结束即可。具体可以参考QWorkerDemo里的代码。

(2)、使用同步对象来完成对数据的访问

Delphi/C++ Builder提供了丰富的线程同步对象,如临界(TCriticalSection)、事件(TEvent)、自旋锁(TSpinLock)等,具体用法就不在这里列了。临界和事件在QWorker中多处用到了,可以自行阅读源码参考,至于其它的,请自行参考帮助或问度娘和谷哥。

本文暂时讨论到这里,有其它问题我们进一步补充说明。

 

分享到: