周沭玲,金 楠,侯海平
随着互联网技术的快速发展,各种应用软件层出不穷,例如即时通讯软件、办公软件、信息资讯、购物娱乐软件等,用户花在软件上的时间越来越多,时间不断被各种软件割裂,用户时间的碎片化越趋明显.通过应用软件占领用户的时间、增加用户粘度是企业追求的目标,实现这一目标的关键就是提升应用软件操作的用户体验.用户的操作响应速度则是提升应用软件用户体验的关键因素之一,通常一个用户无法忍受3~5 秒以上的响应等待.例如Android 操作系统中服务的响应时间要求为10 秒以内,广播消息的响应时间为10~60 秒,UI 操作的响应时间为5 秒以内.当程序响应超出这个时间,就会出现卡顿假死状态,用户被迫等待,无法进行软件的下一步操作[1].这就会造成用户放弃使用或者卸载该软件,软件用户的流失对于互联网时代的企业是无法接受的,因此减少UI 线程阻塞成为优化软件性能的主要研究对象[2].
大多数基于不同平台的开发框架都支持线程技术,开发者可以将耗时费力的工作任务迁移到子线程中去运行,从而减少主线程或者UI 线程压力[3],做到快速响应用户操作,然而这种解决传统单UI 线程阻塞问题的方法并不适合多UI 控件高并发访问场景.
本文提出一套多UI 线程高并发的解决方案,涉及多UI 线程、操作系统消息机制、子线程通信等知识.整体实验过程如下:首先,还原传统单UI 线程阻塞问题的解决方法;再次,模拟单个UI 线程高并发访问的问题场景,发现使用传统方法无法解决阻塞问题,从而引出操作系统消息机制;第三,使用操作系统消息机制解决单个UI 控件高并发访问的阻塞问题;最后,模拟多个UI 控件高并发访问的阻塞问题,提出将每个UI 控件放入独立UI 线程中的解决方法,利用操作系统消息机制实现多个子线程与多个UI 线程通信,最终实现多个UI 控件高并发条件下也能够实时刷新.
以下实验全部在Windows 操作系统环境中完成,采用Windows Presentation Foundation开发框架技术实现实验功能,下文统一简称WPF.
传统业务场景中常见的问题如“下载数据时更新UI 界面中的进度条”“用户使用网络时实时监控网络流量和速度”“接收通知消息显示到UI 界面中”等,通过建立子线程或服务程序都能得到很好地解决[4].在主线程或UI线程中开启子线程或服务,将上述耗时且易产生线程阻塞的工作任务添加到子线程或服务中执行,等到子线程或服务中的任务执行完成后,系统再回传完成的消息给主线程,继而完成一次耗时任务处理[5].可以看出,开启子线程或服务这种方法能够较好处理此类问题.
如果软件在相对较短的时间内加载较少图片时(加载图片相当于线程中的工作任务),并不会暴露出软件的性能问题.但如果切换到新场景中,多人共同操作UI 界面,需要将软件加载图片的数量增加到10 000 张(相当于UI 线程工作任务量较大,此处可以看成是一个耗时任务),甚至更多的图片,实验结果发现此时UI 控件则会出现假死状态,即使提高计算机性能配置也很难改变这一现象,因为无法知道用户是否需要加载更多的图片.
为了能够解决上述问题,尝试采用1.1 中常规处理方法.模拟实验过程为:主线程创建ListView 控件用于呈现10 000 张图片,而呈现10 000 张图片是一件非常耗费时间的任务,按照1.1 中处理方法需要将这个耗时任务放到子线程中去完成,结果发现这样并不能启动这个子线程,因为它违背了WPF 线程亲缘性规则.WPF 线程亲缘性要求控件的创建和使用必须在同一个线程中,而当前情形是在主线程中创建Listview,在子线程中访问Listview,两个线程同时拥有一个控件,这是不被WPF开发框架允许的,因此实验失败.
在监控多个客户端数据的场景中,作为服务器一端实时获取多个客户端数据,并同时呈现到UI 界面上多个控件中,服务器端程序UI 界面中每一个控件对应一个客户端,并负责呈现对应客户端的数据,如果其中一个控件处在高并发处理数据中,则整个UI 界面会出现假死状态,其他控件更是无法处理对应的客户端数据.
同样尝试采用上述子线程方法处理多个客户端数据.模拟实验过程为:主线程中创建多个UI 控件,根据客户端创建对应的子线程,有多少客户端就建立多少个子线程,每个子线程用于接收客户端数据,根据前述实验结论可以发现不能在子线程中操作其他线程创建的控件.另外,如果在子线程中采用循环方式采集对应客户端的数据,也非常容易造成UI 线程阻塞,因为这些数据最终还是要通过循环方式加载到UI 控件中,循环加载是造成UI 线程阻塞的主要因素.因此,采用传统子线程处理耗时任务的方式并不能成功解决UI 控件高并发问题.
WPF 中UI 控件造成卡顿假死现象是由于UI 控件所在线程阻塞造成的.WPF 消息机制给解决此类问题提供了可能性.其原理如图1所示.
步骤1:Windows 操作系统收到中断消息,使用PostMessage()方法将消息发送给Message Queue(消息队列),这些中断消息可以是用户鼠标的点击、键盘的输入,也可以是封装的Message 消息.在使用PostMessage()发送消息时,将最新的Message 插入到Message Queue 尾部.
步 骤2:调 用Dispatcher.PushFrameImpl()方法消费Message Queue 中的消息,这是一种循环机制,Dispatch 内部通过GetMessage()方法不断地从Message Queue 中获取消息.
步骤3:在WPF 中,Dispatcher 将获取的消息分发到指定的窗口,对于一个WPF 程序来说将会有一个隐藏的窗口来接收分发的消息.
步骤4:这个隐藏窗口使用类似Win32 系统中WndProc()方法处理收到的消息,从而更新UI 界面.
步骤5:如果当前窗口又产生新的消息,将再交由Windows 操作系统来处理消息,进入下一轮循环.
通常UI 线程阻塞是因为在当前窗口处理的任务过大,耗时过多,任务不能及时处理,造成UI 线程不能接收Windows 操作系统传来的消息造成的.例如UI 线程在处理一个大任务(加载10 000 张图片)时,Windows 操作系统的消息就无法及时传递到当前窗口,导致UI界面假死状态.窗口标题栏会出现“没有响应”字样.
图1 WPF 框架中Windows 消息机制时序图
对以上过程中步骤4 进行分析,如果UI线程接收到的操作系统消息指令是处理一个工作量较大的任务时,可以将工作量过大的任务切分成一个个小的任务,每一个小的任务完成后,向Windows 操作系统传递一个消息,从而保证UI 线程可以正常接收到Windows 操作系统的消息,让当前UI 线程有响应.例如:在处理加载10 000 张图片时,如果等10 000 张图片加载完成再去更新UI 线程,就会造成长时间阻塞UI 线程.因此不必一次加载全部图片,可以一次只加载10 张图片,然后通过发送消息给操作系统更新一次UI 线程,总的任务就可以分解成1 000 次去更新UI 线程,从而在界面响应上保证用户的体验,这种方法称为“拆分任务”.通过“拆分任务”在上一个任务处理和下一个任务处理的空档中,利用WPF 的消息机制将消息传递到当前窗口进行处理,保证UI 线程及时响应.
WPF 对应用程序中产生的消息使用DispatcherOperation 进行了封装,这种封装暴露了消息的优先级Priority,定义了DispatcherOperation 消息的结束事件和取消事件.通过Dispatcher 对象创建消息、处理窗口消息形成消息产生到消费的闭环,这就为解决UI 线程阻塞提供了可能性.具体做法如下:
步骤1:开发者调用Dispatcher 的Invoke 或BeginInvoke,发送DispatcherOperation 消息,确定消息的Priority 级别.
步骤2:该DispatcherOperation 消息加入到DispatcherOperation 消息队列中,也就是之前所说的Message Queue 中.
步骤3:对应的隐藏窗口收到Dispatcher-Operation 消息,按优先级执行该消息中包含的任务.
步骤4:UI 线程更新.
根据这一过程分析得到,在拆分任务时使用Dispatcher 向系统消息队列发送任务消息,能够保证UI 线程及时更新,运行过程不阻塞.
Dispatcher 对象给开发者解决UI 线程阻塞带来了可能性,Dispatcher 提供了Invoke 和BeginInvoke 方法,使用这两个方法向Dispatcher-Operation 的消息队列发送消息,一方面可以保证在UI 线程中进行任务拆分并及时更新UI 线程,另一方面也可以保证子线程完成耗时任务后发送消息回到UI 线程,更新UI 线程.这两个方法的具体描述如下:
(1)Invoke 的方法签名及使用场景
object Invoke(Delegate method,object[]args);
参数1method 是一个委托类型,可以理解为是一个方法的地址,表示发送到Dispatcher-Operation 消息队列中的一个任务方法;参数2args 是这个方法调用时传入的参数值,这样就将一个要执行的任务传递给Windows 消息机制处理.
Invoke 方法用于同步处理场景,当用户需要等待方法执行返回结果才能继续往下执行时采用Invoke 方法,它可以保证消息传递过程中消息保持一定顺序被执行.但是如果该消息中含有较大执行任务,也就是该委托对应的方法中执行的程序耗时比较长时,会造成线程阻塞.
(2)BeginInvoke 的方法签名及使用场景
IAsyncResultBeginInvoke(Delegate method,object[]args);
参数的表达意思同Invoke 方法.参数1 表示委托,参数2 表示方法执行时传递的参数值.不同的是该方法用于异步处理场景,当用户不需要等待method 参数方法执行完毕就继续往下执行其他程序时,可以采用该方法.它虽然不能保证消息按顺序地执行完成,但是可以保证程序很好的性能,从而提供给用户较好的体验.
对于使用BeginInvoke 方法产生消息的乱序,可以通过在进行参数传递时提供时间戳来标记消息的先后顺序.然后通过定时器定时获取一组已经有序的消息并执行它们,为了保证性能问题,需要通过多轮测试最终选取定时器的间隔时间和一组消息的组大小.
为了方便展示实验过程,建立一个数据采集系统,设置两个终端持续不断地将数据发送到程序主界面,接收方软件主界面通过两个区域的UI 可视化控件来展示这些由终端1 和终端2 发出的数据.为了方便观察效果,这里将数据以点的形式绘制在界面上.具体场景结构关系如图2 所示.
图2 多终端数据展示过程
终端1 持续不断地将数据发送到区域1控件,区域1 持续不断地将这些数据以点的形式绘制在区域1 的位置;终端2 持续不断地将数据发送到区域2 控件,区域2 持续不断地将这些数据以点的形式绘制在区域2 的位置.问题场景中,终端与程序之间的通信是建立在网络环境中,终端需要知道程序所在服务器的IP 地址,程序需要知道终端的唯一标识,让服务器程序能清楚知道是谁发送过来的.实验过程中发现存在两个问题.
(1)两个终端的数据展示工作使用单UI线程将无法完成,必须为每一区域内的数据展示过程建立独立的UI 线程去处理数据绘制工作,两个终端需要建立两个UI 线程.
(2)终端数据是通过循环不断向外发出的,如果将这些点直接绘制在UI 控件上,UI线程会立即造成阻塞.实验时看到的画面将是所有消息发送完毕,这些点一次展示到UI界面上,这与实时展示数据点是不相符的.
WPF 开发框架提供了VisualTarget 类给程序创建多UI 线程带来了可能性,创建多UI 线程的好处就是为每一个UI 线程建立自己的消息循环队列,每个UI 控件可以在自己的消息循环队列中使用GetMessage()获取消息,相互不干扰,根据前面问题场景的模拟,可以建立两个UI 线程,以下是使用VisualTarget 类创建多UI 线程的步骤.
步骤1:创建一个自定义类继承FrameworkElement,其目的是建立新UI 线程中控件的宿主,将新的UI 线程中的UI 控件加入到当前UI 控件的可视化树中.
步骤2:实例化刚刚创建的可视化宿主类,将WPF 框架提供的HostVisual 实例化后加入其中,并将可视化宿主类实例加入到当前UI 控件可视化树中.
步骤3:建立子线程,在子线程中创建每个区域绘制数据点的UI 控件,这里选择WPF框架中的InkCanvas 控件,并使用VisualTarget类创建一个实例,将HostVisual 实例加入其中,这样就将子线程中UI 控件与可视化树中的宿主建立了联系,在WPF 中每一个UI 控件必须在可视化树中挂载才可以显示.
步骤4:将创建的线程设置为单线程单元状态,也就是让当前线程可以建立独立消息循环队列.
步骤5:重复上面步骤,创建第二个UI 线程,至此两个UI 线程创建完毕.
根据之前实验结论可以知道使用循环方式在InkCanvas 控件上展示数据点,会出现线程阻塞状态,直接导致所有数据收集完毕后所有数据点一次性展示,给用户造成的视觉感受就是没有中间过程,要么没有数据点,要么一次将一万个点一次展示,中间过程界面是假死状态.要想解决UI 线程阻塞问题就必须 引 入Dispatcher 的Invoke 和BeginInvoke 方法.通过前述分析,可以知道Invoke 方法对于数据量大时,也会造成当前线程阻塞,使用BeginInvoke 方法则会造成执行乱序,这里可以采用将两种方法结合的方式来处理,将每次执行BeginInvoke 方法之间的间隔时间稍微增大,降低乱序发生的可能性,将数据累积到一个小批量后使用Invoke 方法执行,保证顺序的正确性.所以,在外侧循环使用BeginInvoke 方法,在内侧循环使用Invoke,降低Invoke循环执行的次数,例如100 以内,因为数字过大执行时间过长会造成线程阻塞.
通过使用VisualTarget 创建了两个UI 线程,并且在每一个UI 线程中建立展示数据的InkCanvas,解决了多线程创建和数据展示问题;通过使用Dispatcher 的Invoke 和BeginInvoke方法,利用WPF 消息机制很好地解决了大量数据处理造成多UI 线程阻塞的问题.最终可以看到图3 效果.
图3 数据点展示过程
图3 中(a)(b)(c)呈现了程序执行的动态过程.点击按钮后,开始收集数据,数据点展示是动态变化的,并且是左右两个区域同时展示,展示过程中UI 界面按钮是可以点击的状态,表示两个UI 线程并没有阻塞.
UI 线程阻塞问题是软件开发过程中处理用户体验问题的关键,在WPF 开发框架中可以利用Windows 消息机制很好地处理UI 线程阻 塞问题,WPF 提供了Dispatcher 的Invoke 和BeginInvoke 方法,可以使用这两种方法向Windows 消息队列发送信息更新UI 线程.同时,在多UI 线程场景中,可以利用VisualTarget 和HostVisual 建立多UI 线程控件的宿主,将多个UI 线程中的控件连接到同一个WPF 可视化树中,再运用Windows 消息机制解决多UI 线程阻塞问题.该解决方案优化了多UI 线程高并发访问的处理效率,即使在并发处理的任务较大时,也能通过“拆分任务”很好地解决多UI 线程的阻塞问题,大大改善和提升了应用软件的用户体验.