IT培训网 - IT职场人学IT技术上IT培训网
多线程操作一个UI容易导致反向加锁和死锁吗
时间:2019-01-03 13:03:55 来源:我爱设计网 作者:IT培训网 已有:名学员访问该课程
为什么大多数程序子线程都不能刷新UI?
例如常用的Qt,每次子线程去更新Ui都要通过信号槽去刷。
相关问题:
GUI界面线程Suspend死锁问题? - 图形用户界面
1、GUI为了性能,故意让你只能在一个线程里面操作的,让你每次都QueueUserAPC / SendMessage一把。为了让你省下一点点麻烦而把整套GUI做成可重入的,不仅要难一万倍,性能直线下降,而且还没有任何好处。
2、以前Sun的副总裁曾经写过一篇Blog专门谈这个问题。多线程操作一个UI,很容易导致,或者极其容易导致反向加锁和死锁问题。
无数人已经在这上面踩过无数的坑了。
所以单线程何乐而不为?
你想想看,既然单线程是最保险的。难道告诉开发者所有逻辑都写在一个线程里不是最简单的实现? 开发者还要假装开启某些不能并发的线程,还要让后台的框架把线程里的任务再统一接回一个线程,还要考虑偏序的问题,这不是吃饱了撑的。
3、现在我从 windows 开发角度,并且用一个基本的 demo 来演示。
这个 demo 属于基本的练习题目之一,用一个进度条,一个或多个 static 文本,显示从网络上下载一个文件(http 资源)的进度。
它的基本 ui 形如这样:
这里主要有两个线程:
(1)UI线程:也就是主线程,它创建了所有 ui 元素,包括主对话框,主对话框又继续创建了 ProgressBar,Button,Static,TextBox 等窗口元素。
(2)后台线程:完成对 http 资源的下载。
首先提出一个问题,为什么不能在 UI 线程中直接下载?
答案很简单:
因为 UI 线程负责和用户交互,负责 GetMessage 和调度处理 Message,因此不能长时间
阻塞。因此对于下载文件,这样一个耗时任务,必须开启一个后台线程来完成。
现在,我们遇到一个难点,上面两个线程在怎样的情况下,会发生死锁。如果避免死锁,应该让它们如何交互?
第一,一个很重要的线程同步函数,就是等待功能,即 WaitForSingleObject,在这里具体化,就是 UI 线程,阻塞自己,等待后台线程结束。这个功能很重要,因为它负责维持完成工作的有序。
第二,后台线程在下载过程中,希望通过 UI 对用户进行进度反馈。这一步有很多做法,比如说,在后台线程中直接,间接调用 SendMessage 给 UI 线程创建的窗口更新进度条,Static 的文本。
备注:什么叫做直接或间接的调用 SendMessage ?
包括:
直接的 SendMessage,
间接的 SendDlgItemMessage,SetDlgItemText,SetDlgItemInt 等 windows api,ComboBox_SetCurSel 等 SDK 或用户定义的宏。
更新 UI 通常都是 SendMessage 给控件窗口,例如让 ComboBox 设置某个选择项,是发送:CB_SETCURSEL,对进度条设置某个进度,是发送:PBM_SETPOS,设置窗口文本,是发送 WM_SETTEXT,等等。
当控件是本线程创建的,SendMessage 等同于直接调用目标窗口过程
(想象一下,自己等待自己,是无意义的)。
控件由其他线程创建时,依赖创建该控件的线程的运转,处理线程队列来处理这个消息。
如果采用(1)和(2)的方式,两个线程就会发生死锁。
因为对于(2)来说,更新 UI 时(因为属于跨线程),阻塞自身,并依赖(1)的正常运转!!!
对于(1) 来说,阻塞自身,依赖 (2)运行到退出。
可以很明显的看到,如果两个线程,都采用“阻塞自己,等待对方运转”的操作,则它们发生闭环性,阻塞性等待。死锁发生。(尽管刷新 UI 需要的时间实际上非常短暂。)
我对上面的死锁情况画出下面的简要示意图(以下是我手工画的草图,因为不是技术博客,所以就不需要那么认真了。):
注意,当 UI 线程,在等待后台线程时(红色箭头),这时 UI 线程就被卡在原地不动。当后台线程调用 SendMessage 时,后台线程又被卡住!(因为 UI 线程的消息循环停滞,所以蓝色的返回箭头无法形成。)这时候两个线程谁也动不了,两者都需要对方的推进,即相互锁住。在上面的示意图中,仔细看箭头的次序可以发现,那些箭头是走不动的。
如果后台线程调用了 SendMessage 并且被 UI 线程的消息循环响应之后, UI 线程再调用 WaitForSingleObject,那么当然这时候两个线程都很 happy 的得到满意结果。
当然,线程的消息队列,又有 send message list,post message list, 和普通的 message list 之分,不过不影响讲解。对 send message 和 post message 的处理的具体细节,可以参考 <> 对于 windows 系统内部消息处理的讲解。
现在两个操作都是重要的,那么如何解开上面的死锁呢?
(1)这种 Wait 因为需求,通常无法变动;当线程 A 有等待线程 B 的需要,则线程 B 就不能阻塞自己来等待 A。
因此如果 UI 线程有等待需要,那我们需要修改 (2),(2) 不能调用 SendMessage 这种阻塞性调用。而是调用 PostMessage,就可以避免阻塞自己。
PostMessage 和 SendMessage 的作用高度相似,只是,它是非阻塞性的,因此,它也无法立即得到消息的处理结果,即 PostMessage 没有返回值。好比一个信鸽,SendMessage 必须等待对方,带回一个反馈消息,PostMessage 只管送出消息,而不关心对方什么时间处理,不关心对方的反馈。
PostMessage 和 SendMessage 把消息发往创建该窗口的线程的消息队列。该线程的入口函数,负责处理消息队列,包括,Get 和 Dispatch 这个消息,后者即根据窗口所在的类,调用该窗口的窗口过程。(因此,由是可见,WndProc 是回调函数,因为它通常由用户提供的,在DispatchMessage 时被系统回调)。
但是 PostMessage (相对于 SendMessage 来说)有一个局限,那就它不能传递自身的位于栈上的临时性内存空间,供消息的接收方使用(而 SendMessage 可以安全的传递栈上的临时内存空间给消息处理方)。因为当消息接受方处理消息时,它无法使用一个“生命周期可能已失效”的缓冲区。
如果采用每次都动态在堆上 new 一个内存空间,然后通过 lParam 传递给消息处理方,消息处理时,再释放这个内存空间,固然可以。但是这种做法显得很别扭,让人难以接受,比较违背 C++ 内存管理单方负责的原则,不是通常的 C++ 模式。
总结以下:
PostMessage 发出的消息是优先级最低的消息(相对于 SendMessage 和 进入常规消息队列的其他消息来说),在“对方”处于 idle 时得到处理。它不会在两个线程之间产生阻塞/等待关系,局限是不能传递临时性内存空间给接收方。
SendMessage 会让两个线程发生阻塞/等待关系。
因此,采用下面的方式,在 后台线程和 UI 线程之间交互:
把传递给 UI 的数据,用一个生命周期持久的缓冲区来存储。可以是在堆上申请的内存,也可以是全局变量。
为了普遍性期间,这个数据的尺寸,是相对的较大一点,比如说,含有多个基本数据,包括,已经下载的字节数,文件大小,进度(百分比),下载速度,剩余时间(估算),其他任何辅助性数据。
对于这样一个包含多个数据的内存区域,线程 (1)用来更新 ui,线程(2)用来填充。因此,我们最好保持一下这个数据的“完整性”。也就是说,我们对这个数据的读写,包装到一个临界代码段中。虽然,保持数据完整性的需求,在这里显得没有那么迫切和紧要,也会让多线程运行的不够“流畅”,但是这点成本很小,便可以让我们的代码变得更清晰,稳定,可以信赖。
所以,线程(1)(2)在访问这个数据时,需要由 Enter / Leave Critical Section 包夹住。
线程(2)下载了一部分数据,反馈进度时,进入临界区填充数据,然后对(1) 创建的对话框,Post 一个 Message。
线程(1)通过消息循环,处理 Post message,调用对应处理函数,该处理函数进入临界区,把对应的数据,刷新到 UI 界面(在这里就可以放心的调用 SendMessage 了)。
由于上面的过程中,和具体 UI 耦合度有点大,所以为了让下载 http 文件的代码,能够通用,适应不同的具体 UI,最终它的形式是这样的:
typedef struct tagDOWNLOAD_STEP{ unsigned int FileSize; unsigned int DownloadedSize; //完成百分比[0-100] int Percent; //网速,Bytes / Second; unsigned int DownloadSpeed; //剩余时间估计,MilliSeconds; (< 0 时表示不可知) int RemainTime; //user data; LPARAM lParam;} DOWNLOAD_STEP, *LPDOWNLOAD_STEP;//正在下载文件的回调函数,返回值:// FALSE - 中断下载;// TRUE - 继续下载;typedef BOOL (CALLBACK *PROC_DOWNLOADING)(LPDOWNLOAD_STEP pStep);//下载一个文件BOOL HttpDownload(LPCTSTR szUrl, //remote url; LPCTSTR szLocalFileName, //save as; int StartFilePos, LPTSTR szErrMsg, //error msg if fail; UINT cchMsgSize, //szErrMsg's size (in TCHARs); LPUINT pFileSize, BOOL bForceReload, PROC_DOWNLOADING pFunc, //用于向调用方反馈下载状态的回调函数; LPARAM lParam); //lParam 传回给回调函数 pFunc;
这里有一个很重要的一点就是,pFunc 这个回调函数,不能直接通过 SendMessage 去刷新 UI,如果通过 SendMessage 刷新 UI,也可以,则 UI 线程不能调用 WaitForSingleObject 等待后台线程结束。否则会死锁。因此,如果 UI 线程不需要等待后台线程,则 pFunc 可以调用 SendMessage 直接刷 UI。这时后台线程可以阻塞性等待 UI 线程,UI 线程从不阻塞。
如果 UI 线程需要等待后台线程结束,则 pFunc 就不可以(直接或间接)调用 SendMessage 给 UI 线程创建的任何窗口。而是应该通过上面我讲的方式,只能采用 PostMessage,加上一个长生命周期的沟通数据,访问数据的代码放在临界区,控制两个线程进行独占性访问。
因此,原则是,阻塞自身的等待不能发生闭环。
对于只有两个线程的情况,UI 线程和后台线程,他们只能有一个线程采用阻塞性等待对方。
UI 线程的 WaitForSingleObject 等待后台线程结束,和 后台线程的 SendMessage 给 UI 线程创建的窗口,都属于阻塞自己来等待对方。所以上面两者只能取其一。
综合以上,你应该明确下面三种情况:
(1)如果 UI 线程中用了 WaitForSingleObject,则后台线程就不可以(直接或间接)调用 SendMessage 更新 UI。
(2)如果后台线程只调用 PostMessage(不调用 SendMessage),则意味着后台线程总不会阻塞,在 UI 线程中可以安全的等待它。
(3)如果 UI 线程中没有调用 WaitForSingleObject,则后台线程中,可以通过(直接或间接) SendMessage 的方式刷新 UI。
----
2016年7月18日:
这是原来的鼠标手绘的插图:
时间久了,一直觉得丑的很,想想自己答案里放了这么不正规的图很是无法接受,强迫症犯了,于是重新用 PS 画了新的插图。
每期开班座位有限.0元试听抢座开始!
温馨提示 : 请保持手机畅通,咨询老师为您
提供专属一对一报名服务。
- 上一篇:零基础怎么学UI设计才最好
- 下一篇:UI设计需要学习代码吗 UI主线程如何学习