马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?站点注册
×
PowerBuilder 共享对象与多线程实战:SharedObjectRegister 的跨会话调用机制与 PostEvent 的异步边界(PB10 基准 · PB12.5 实测)
阅读说明
1. 适用版本:本文以 PowerBuilder 10 为基准编写与验证;SharedObject 系列函数自 PB 8 起提供,PB 8 / 9 / 10 / 10.5 / 11.x / 12.x 语义一致。PB 12.5 已同步实机复跑,行为与 PB 10 完全一致(见第十三节)
2. 支持数据库:本文示例不涉及数据库,纯运行时并发机制,不需要连接任何 DBMS
3. 操作系统与环境要求:Windows XP 及以上(示例依赖系统自带 kernel32.dll 的 GetCurrentThreadId / GetTickCount 两个函数,无需任何第三方库或 DLL);.sru / .srw 源码须 GBK 编码 + CRLF 换行 + 无 BOM
4. 难度系数:★★★☆☆(需要先了解用户对象、实例变量与 PostEvent / TriggerEvent 的基本用法)
5. 其它阅读说明:本文所有结论都来自双版本实机探针(PB 10 与 PB 12.5 各 3 轮,全部断言通过),文中出现的线程号、毫秒数、文件长度都是真机输出原值,不是推断值;文末附可直接导入运行的 PB10 兼容版 PBL 源码包(含两个用户对象与自检脚本)
一、先把"多线程"这件事说清楚:PB 里只有三条路
PowerBuilder 是单线程语言——没有任何语法可以让你"起一个线程"。但这不代表它做不了并发。可用的路只有三条:
| 路线 | 机制 | 是否真并行 | 典型场景 | 局限 | | ① 协作式假并发 | 窗口 Timer 事件 + Yield() | 否 | 进度条刷新、分片处理大量数据 | 仍然只有一个线程;Yield() 要靠消息队列,无窗口/服务端环境常常无效 | | ② 共享对象 | SharedObjectRegister 系列 | 是(独立会话 + 独立线程) | 后台长任务、批量计算、不冻结界面 | 调用与取值都是"跨会话",有一套自己的坑(本文重点) | | ③ 外部线程 | 自己写 C++ DLL 起线程,或第三方封装(如 PBIDEA 的 uo_thread) | 是 | 需要与 PB 侧深度交互的重并发 | 需要额外的二进制与许可;PB 侧只有一个回调入口,交互模型要自己设计 |
路 ② 是 PowerBuilder 语言层唯一的官方并发手段,一共只有四个函数,本文把它们一次讲透。很多人(包括不少老 PB 程序员)对它的印象是"共享对象 = 后台线程,调用它是异步的",这个印象是错的——本文的核心就是拿实测数据把这个边界钉死。
二、四个 API 全解
| 函数 | 作用 | 返回值 | | SharedObjectRegister( classname, instancename ) | 在一个新的 PB 会话(新线程)里创建 classname 的一个实例,并把它登记为 instancename | errorreturn 枚举 | | SharedObjectGet( instancename, objectinstance ) | 按名字取出共享实例的引用,赋给一个同类型的变量 | errorreturn 枚举 | | SharedObjectUnregister( instancename ) | 注销名字(释放这个名字,实例本身不一定立刻销毁) | errorreturn 枚举 | | SharedObjectDirectory( instancenames[], classnames[] ) | 枚举当前进程内所有已登记的共享实例(两个数组按位置对应) | errorreturn 枚举 |
六个返回枚举值(PB 帮助里有,但要理解各自的语义):
| 枚举值 | 含义 | | Success! | 成功 | | SharedObjectExistsError! | 这个名字已经被用过了 | | SharedObjectCreateInstanceError! | 会话建起来了,但实例没造出来(比如类无法实例化) | | SharedObjectCreatePBSessionError! | 新的 PB 会话创建失败 | | SharedObjectNotExistsError! | 这个名字不存在 | | FeatureNotSupportedError! | 当前环境不支持该特性 |
两个必须记住的语法要点:
- 接受引用的变量必须与被注册的类同类型,否则编译期就报 C0008(类型不兼容);
- 函数返回的是 errorreturn 枚举,不要用 boolean 去接——要判断成功就写 ler = Success!,想把它变成文字就 String(ler),实测会返回枚举字面名(如 "SharedObjectCreatePBSessionError!"),调试时很好用。
三、返回枚举实测:把错误码一次钉死
空口说"返回失败"没有意义,得知道每一种错法对应哪一个枚举。实测结果(PB 10 与 PB 12.5 完全一致):
| 测试动作 | 实测返回 | 说明 | | 同名实例注册第二次 | SharedObjectExistsError! | 名字是进程内全局的,重名就报这个 | | SharedObjectGet 一个不存在的名字 | SharedObjectNotExistsError! | 并且传入的引用保持 invalid(IsValid = false) | | SharedObjectUnregister 一个不存在的名字 | SharedObjectNotExistsError! | 不会崩,只是告诉你没有 | | SharedObjectRegister('nvo_no_such_class', 'sw9') 类名写错 | SharedObjectCreatePBSessionError! | ★反直觉:报的是"会话创建失败",而不是"实例创建失败" | | 上一步失败后再 SharedObjectGet('sw9', ...) | SharedObjectNotExistsError! | 失败不会留下半个登记项 |
注意最后一行那个"反直觉":类名写错时,PB 报的是 SharedObjectCreatePBSessionError!,SharedObjectCreateInstanceError! 在整个探针里一次都没被触发。所以排错时不要盯着 CreateInstanceError 找——看到 CreatePBSessionError 就要先检查类名拼写、类是否在当前 PBL 库里、以及被依赖的对象是否已编译。
实测输出(原文摘录):
- reg5=true dup=true badclass_session=true badclass_inst=false getmiss=true refvalid=false dir_cnt=1 item1=sw5/nvo_so_worker unreg=true
复制代码
四、完整示例:从注册到注销
4.1 操作步骤(照这个顺序做就能跑)
- 新建一个 PBL(或用一个空的目标 PBL),在里面新建一个定制类用户对象(Custom Class),命名 nvo_so_worker,祖先保持 nonvisualobject;
- 把下面的 nvo_so_worker 源码整段粘进去(它就是"要在后台干的活");
- 再新建一个定制类用户对象 nvo_so_article,祖先同样 nonvisualobject,粘入第二段源码(它是"调用方封装");
- 在窗口上放一个按钮,clicked 事件里粘入第三段(不到十行);
- 运行,点按钮,看弹出来的结果。
4.2 后台工作者:nvo_so_worker
- $PBExportHeader$nvo_so_worker.sru
- forward
- global type nvo_so_worker from nonvisualobject
- end type
- end forward
- global type nvo_so_worker from nonvisualobject
- event ue_work ( )
- end type
- global nvo_so_worker nvo_so_worker
- type variables
- long il_progress
- boolean ib_done
- long il_arg
- ulong il_t_start
- ulong il_t_end
- long il_ctor_thread
- end variables
- forward prototypes
- public function long of_ping ()
- public function long of_work (long al_total)
- public subroutine of_start (long al_total)
- public function long of_ctorthread ()
- public subroutine of_log (string as_line)
- end prototypes
- type prototypes
- public:
- function ulong GetCurrentThreadId() library "kernel32.dll"
- function ulong GetTickCount() library "kernel32.dll"
- end prototypes
- on nvo_so_worker.create
- call super::create
- TriggerEvent( this, "constructor" )
- end on
- on nvo_so_worker.destroy
- TriggerEvent( this, "destructor" )
- call super::destroy
- end on
- event constructor;
- //记下「这个实例是在哪个线程上构造出来的」,用来和主线程对照
- il_ctor_thread = GetCurrentThreadId()
- end event
- event destructor;
- //销毁时把当前线程号追加进 so_dtor.txt,观察销毁发生在哪个线程
- long ll_f
- ll_f = FileOpen('so_dtor.txt', LineMode!, Write!, LockWrite!, Append!)
- if ll_f > 0 then
- FileWrite(ll_f, 'DTOR thread=' + String(GetCurrentThreadId()))
- FileClose(ll_f)
- end if
- end event
- event ue_work;
- //PostEvent / TriggerEvent 调用的入口:参数走实例变量,过程把进度写进 il_progress
- //每推进到 1/4 处,就往 so_prog.txt 追加一行带 GetTickCount 时间戳的记录
- long ll_i
- long ll_step
- il_progress = 0
- ib_done = false
- il_t_start = GetTickCount()
- of_log('W_T0=' + String(il_t_start))
- ll_step = il_arg / 4
- if ll_step < 1 then ll_step = 1
- for ll_i = 1 to il_arg
- il_progress = ll_i
- if Mod(ll_i, ll_step) = 0 then
- of_log('W_PROG=' + String(ll_i) + '@' + String(GetTickCount()))
- end if
- next
- il_t_end = GetTickCount()
- ib_done = true
- of_log('W_T1=' + String(il_t_end))
- end event
- public subroutine of_log (string as_line);
- //把一行带时间戳的进度追加到 so_prog.txt
- long ll_f
- ll_f = FileOpen('so_prog.txt', LineMode!, Write!, LockWrite!, Append!)
- if ll_f > 0 then
- FileWrite(ll_f, as_line)
- FileClose(ll_f)
- end if
- end subroutine
- public function long of_ping ();
- //返回当前线程号:调用它就能知道「这次调用是在哪个线程上跑的」
- return GetCurrentThreadId()
- end function
- public function long of_ctorthread ();
- //返回构造时的线程号
- return il_ctor_thread
- end function
- public function long of_work (long al_total);
- //模拟耗时任务:纯计算忙循环(有返回值,同步返回)
- long ll_i
- il_progress = 0
- ib_done = false
- il_t_start = GetTickCount()
- for ll_i = 1 to al_total
- il_progress = ll_i
- next
- il_t_end = GetTickCount()
- ib_done = true
- return il_progress
- end function
- public subroutine of_start (long al_total);
- //与 of_work 逻辑完全相同,唯一区别是【没有返回值】
- long ll_i
- il_progress = 0
- ib_done = false
- il_t_start = GetTickCount()
- for ll_i = 1 to al_total
- il_progress = ll_i
- next
- il_t_end = GetTickCount()
- ib_done = true
- end subroutine
复制代码
of_work 与 of_start 的存在意义:这两个方法逻辑一模一样,唯一差别是"有没有返回值"。第十节会拿它们对照证明——"没有返回值"并不能让它变成异步。这是社区里最容易想当然的一个点。
4.3 调用方封装:nvo_so_article
- $PBExportHeader$nvo_so_article.sru
- forward
- global type nvo_so_article from nonvisualobject
- end type
- end forward
- global type nvo_so_article from nonvisualobject
- end type
- global nvo_so_article nvo_so_article
- type variables
- nvo_so_worker iuo_w
- end variables
- forward prototypes
- public function string of_demo_register ()
- public function string of_demo_sync_call (long al_total)
- public function string of_demo_postevent (long al_total)
- public function string of_demo_lifecycle ()
- public function string of_demo_errcode ()
- public function string of_read_progress_log ()
- public function string of_run_pick (int ai_pick, long al_total)
- end prototypes
- type prototypes
- public:
- function ulong GetCurrentThreadId() library "kernel32.dll"
- function ulong GetTickCount() library "kernel32.dll"
- end prototypes
- public function string of_demo_register ();
- //演示一:注册 / 取引用 / 线程身份对照
- string ls_out
- ulong lul_main
- ulong lul_so
- errorreturn ler
- lul_main = GetCurrentThreadId()
- ler = SharedObjectRegister('nvo_so_worker', 'sw1')
- ls_out = 'reg=' + String(ler = Success!)
- ler = SharedObjectGet('sw1', iuo_w)
- ls_out = ls_out + ' get=' + String(ler = Success!) + ' refok=' + String(IsValid(iuo_w))
- lul_so = iuo_w.of_ping()
- ls_out = ls_out + ' main_thread=' + String(lul_main) + ' so_thread=' + String(lul_so)
- ls_out = ls_out + ' ctor_eq_ping=' + String(iuo_w.of_ctorthread() = iuo_w.of_ping())
- ls_out = ls_out + ' thread_differs=' + String(lul_so <> lul_main)
- ler = SharedObjectUnregister('sw1')
- ls_out = ls_out + ' unreg=' + String(ler = Success!)
- return ls_out
- end function
- public function string of_demo_sync_call (long al_total);
- //演示二:有返回值的成员函数 / 无返回值的子程序调用,量一下调用耗时
- string ls_out
- long ll_p
- ulong lul_c0
- errorreturn ler
- ler = SharedObjectRegister('nvo_so_worker', 'sw2')
- if ler <> Success! then
- return 'REG_FAIL=' + String(ler)
- end if
- ler = SharedObjectGet('sw2', iuo_w)
- if ler <> Success! then
- return 'GET_FAIL=' + String(ler)
- end if
- iuo_w.il_progress = 0
- iuo_w.ib_done = false
- lul_c0 = GetTickCount()
- ll_p = iuo_w.of_work(al_total)
- ls_out = 'work_elapsed_ms=' + String(GetTickCount() - lul_c0)
- ls_out = ls_out + ' work_ret=' + String(ll_p)
- ls_out = ls_out + ' progress_after_ret=' + String(iuo_w.il_progress)
- ls_out = ls_out + ' done_after_ret=' + String(iuo_w.ib_done)
- iuo_w.il_progress = 0
- iuo_w.ib_done = false
- lul_c0 = GetTickCount()
- iuo_w.of_start(al_total)
- ls_out = ls_out + ' start_elapsed_ms=' + String(GetTickCount() - lul_c0)
- ls_out = ls_out + ' start_progress_after_ret=' + String(iuo_w.il_progress)
- ls_out = ls_out + ' start_done_after_ret=' + String(iuo_w.ib_done)
- ler = SharedObjectUnregister('sw2')
- ls_out = ls_out + ' unreg=' + String(ler = Success!)
- return ls_out
- end function
- public function string of_demo_postevent (long al_total);
- //演示三:PostEvent 派发 + 主线程先干自己的活 + 之后再读进度
- string ls_out
- long ll_p
- long ll_x
- long ll_len
- ulong lul_t0
- ulong lul_r0
- int li_f
- errorreturn ler
- ler = SharedObjectRegister('nvo_so_worker', 'sw3')
- if ler <> Success! then
- return 'REG_FAIL=' + String(ler)
- end if
- ler = SharedObjectGet('sw3', iuo_w)
- if ler <> Success! then
- return 'GET_FAIL=' + String(ler)
- end if
- //先把进度日志清空,确保文件里只有本轮的内容
- li_f = FileOpen('so_prog.txt', LineMode!, Write!, LockWrite!, Replace!)
- FileWrite(li_f, 'BEGIN')
- FileClose(li_f)
- iuo_w.il_progress = 0
- iuo_w.ib_done = false
- iuo_w.il_arg = al_total
- lul_t0 = GetTickCount()
- iuo_w.PostEvent('ue_work')
- ls_out = 'post_ret_ms=' + String(GetTickCount() - lul_t0)
- //主线程做 200ms 纯计算忙等,期间一次都不访问共享对象
- ll_x = 0
- lul_t0 = GetTickCount()
- do while GetTickCount() - lul_t0 < 200
- ll_x = ll_x + 1
- loop
- ls_out = ls_out + ' main_busy_ms=200 main_busy_iter=' + String(ll_x)
- //忙等刚结束时先看进度日志文件:主线程忙的这 200ms 里,工作线程写了多少
- ll_len = FileLength('so_prog.txt')
- ls_out = ls_out + ' prog_len_during_busy=' + String(ll_len)
- if ll_len > 7 then
- ls_out = ls_out + ' worker_ran_during_busy=true'
- else
- ls_out = ls_out + ' worker_ran_during_busy=false'
- end if
- //忙等结束后才去读实例变量:这是一次跨会话读取
- lul_r0 = GetTickCount()
- ll_p = iuo_w.il_progress
- ls_out = ls_out + ' progress=' + String(ll_p)
- ls_out = ls_out + ' read_block_ms=' + String(GetTickCount() - lul_r0)
- ls_out = ls_out + ' done=' + String(iuo_w.ib_done)
- ler = SharedObjectUnregister('sw3')
- ls_out = ls_out + ' unreg=' + String(ler = Success!)
- //任务结束后,用「文件通道」把整份进度日志读回来:这条通道不经过共享对象,不会阻塞
- ll_len = Len(of_read_progress_log())
- if ll_len > 40 then
- ls_out = ls_out + ' file_channel_ok=true'
- else
- ls_out = ls_out + ' file_channel_ok=false'
- end if
- return ls_out
- end function
- public function string of_demo_lifecycle ();
- //演示四:注销之后,旧引用还能不能用、同名能不能立刻重注册
- string ls_out
- long ll_old
- errorreturn ler
- ler = SharedObjectRegister('nvo_so_worker', 'sw4')
- ls_out = 'reg1=' + String(ler = Success!)
- ler = SharedObjectGet('sw4', iuo_w)
- ls_out = ls_out + ' get=' + String(ler = Success!)
- ler = SharedObjectUnregister('sw4')
- ls_out = ls_out + ' unreg=' + String(ler = Success!)
- //注销之后,手里这份旧引用还在
- ll_old = iuo_w.of_ping()
- ls_out = ls_out + ' oldref_thread=' + String(ll_old) + ' oldref_ok=' + String(ll_old > 0)
- //同名实例立刻重注册
- ler = SharedObjectRegister('nvo_so_worker', 'sw4')
- ls_out = ls_out + ' rereg=' + String(ler = Success!)
- ler = SharedObjectUnregister('sw4')
- ls_out = ls_out + ' final_unreg=' + String(ler = Success!)
- return ls_out
- end function
- public function string of_demo_errcode ();
- //演示五:四个 API 的返回枚举实测
- string ls_out
- string lsi[]
- string lsc[]
- nvo_so_worker luo_t
- errorreturn ler
- ler = SharedObjectRegister('nvo_so_worker', 'sw5')
- ls_out = 'reg5=' + String(ler = Success!)
- ler = SharedObjectRegister('nvo_so_worker', 'sw5')
- ls_out = ls_out + ' dup=' + String(ler = SharedObjectExistsError!)
- ler = SharedObjectRegister('nvo_no_such_class', 'sw9')
- ls_out = ls_out + ' badclass_session=' + String(ler = SharedObjectCreatePBSessionError!)
- ls_out = ls_out + ' badclass_inst=' + String(ler = SharedObjectCreateInstanceError!)
- ler = SharedObjectGet('no_such_instance', luo_t)
- ls_out = ls_out + ' getmiss=' + String(ler = SharedObjectNotExistsError!) + ' refvalid=' + String(IsValid(luo_t))
- ler = SharedObjectDirectory(lsi, lsc)
- ls_out = ls_out + ' dir_cnt=' + String(UpperBound(lsi))
- if UpperBound(lsi) > 0 then
- ls_out = ls_out + ' item1=' + lsi[1] + '/' + lsc[1]
- end if
- ler = SharedObjectUnregister('sw5')
- ls_out = ls_out + ' unreg=' + String(ler = Success!)
- return ls_out
- end function
- public function string of_read_progress_log ();
- //把进度日志整份读出来(主线程侧的「文件通道」:不经过共享对象,因此不会阻塞)
- //注意:别用 FileRead 逐行读中文文本;本日志是纯 ASCII,这里可以放心逐行读
- string ls_all
- string ls_line
- int li_f
- ls_all = ''
- li_f = FileOpen('so_prog.txt', LineMode!, Read!, LockRead!)
- if li_f > 0 then
- do while FileRead(li_f, ls_line) <> -100
- ls_all = ls_all + ls_line + '~r~n'
- loop
- FileClose(li_f)
- end if
- return ls_all
- end function
- public function string of_run_pick (int ai_pick, long al_total);
- //按编号分发到对应演示,方便窗口按钮一行调用
- string ls_r
- choose case ai_pick
- case 1
- ls_r = of_demo_register()
- case 2
- ls_r = of_demo_sync_call(al_total)
- case 3
- ls_r = of_demo_postevent(al_total)
- case 4
- ls_r = of_demo_lifecycle()
- case 5
- ls_r = of_demo_errcode()
- case else
- ls_r = '未选择'
- end choose
- return ls_r
- end function
复制代码
4.4 从窗口按钮调用它(输入值写在最前面)
- // 事件:cb_so_run :: clicked
- // 输入:模拟任务的总步数(改大改小都能跑,越大耗时越明显)
- long ll_total
- ll_total = 2000000
- // 输入:要演示哪一项(1 注册 / 2 同步与异步边界 / 3 PostEvent 与文件通道 / 4 生命周期 / 5 错误码)
- int li_pick
- li_pick = 2
- // 输入:共享对象宿主(本文的调用方封装对象)
- nvo_so_article lart
- string ls_r
- lart = create nvo_so_article
- ls_r = lart.of_run_pick(li_pick, ll_total)
- destroy lart
- MessageBox('共享对象演示结果', ls_r)
复制代码
4.5 实机输出(li_pick = 2 时的原文)
- work_elapsed_ms=531 work_ret=2000000 progress_after_ret=2000000 done_after_ret=true start_elapsed_ms=532 start_progress_after_ret=2000000 start_done_after_ret=true unreg=true
复制代码
(上面是 PB 12.5 的值;PB 10 为 work_elapsed_ms=484 start_elapsed_ms=484,其余完全一致。)
五、第一件必须纠正的事:它跑在"另一个会话"里,不是"另一个线程回调你"
5.1 会话身份:线程号完全不同
li_pick = 1 的实测输出:
- reg=true get=true refok=true main_thread=12136 so_thread=6900 ctor_eq_ping=true thread_differs=true unreg=true
复制代码
(PB 10:main_thread=12328 so_thread=12260)
| 观测点 | PB 12.5 | PB 10 | 结论 | | 主会话线程号 | 12136 | 12328 | 主线程身份 | | 共享对象里 of_ping() 返回的线程号 | 6900 | 12260 | 与主线程不同 | | of_ctorthread()(构造时线程)与 of_ping() 是否相同 | true | true | 同一个实例始终在同一个线程上跑 | | 两个不同实例(sw1 / sw2)线程号是否不同 | true | true | 每个共享实例各自有独立线程 |
所以:SharedObjectRegister 每成功一次,PB 就为你新开一个"会话(session)",这个会话有自己的线程、有自己的 PB 运行时上下文。
5.2 ★★ 但成员函数调用是完全同步的
这是整篇文章最关键的一张表。用 of_work(有返回值)和 of_start(无返回值)分别调用同一个 200 万次的忙循环:
| 调用方式 | PB 12.5 调用耗时 | PB 10 调用耗时 | 返回瞬间的进度 | 返回瞬间的完成标志 | | of_work(有返回值函数) | 531 ms | 484 ms | 2000000(已跑完) | true | | of_start(无返回值子程序) | 532 ms | 484 ms | 2000000(已跑完) | true |
读法:如果它是异步的,调用应该"瞬间返回"(0~几毫秒),而实测花了半秒多;而且返回时任务已经全部跑完。
→ 结论:跨会话的成员函数/子程序调用与调用本地对象的方法在时序上没有任何区别,它就是同步阻塞的。"没有返回值"不会让它变异步。
用另一组探针(同一工程里的第二个探针对象,专门压时序)交叉验证,结论一致:
| 调用入口 | PB 12.5 | PB 10 | | of_work(...) 有返回值 | 516 ms | 484 ms | | of_start(...) 无返回值 | 516 ms | 484 ms | | TriggerEvent('ue_work') | 750 ms | 703 ms | | PostEvent('ue_work') | 0 ms | 0 ms |
TriggerEvent 之所以比前两个长一点,是因为 ue_work 里多写了 5 次带时间戳的文件追加,属于任务本身变重,不是机制差异。
5.3 PostEvent 是唯一"立刻返回"的入口——但它不等于"主线程自由了"
PostEvent 实测 0 ms 返回(连 1 个毫秒都没花),这是四个入口里唯一真正"派发完就还给你"的。但请注意下面第六节的证据:主线程真正能"自由"到什么程度,取决于你之后碰不碰这个共享对象。
六、并发硬证据:两条时间线
看数字不如看时间线。li_pick = 3 的实测输出(PB 12.5):
- post_ret_ms=0 main_busy_ms=200 main_busy_iter=90420 prog_len_during_busy=24 worker_ran_during_busy=true progress=2000000 read_block_ms=609 done=true unreg=true file_channel_ok=true
复制代码
(PB 10:main_busy_iter=102603 prog_len_during_busy=24 read_block_ms=547,其余一致。)
把它拆成三条独立证据:
证据 ①:主线程没有被挂起。
PostEvent 返回后,主线程跑了 200 ms 的纯计算忙等,一共迭代了 90420 次(PB 10 是 102603 次)。如果主线程被这次派发挂起了,它根本跑不完这 200 ms。主线程是正常运行的。
证据 ②:同一时间窗内,工作线程也在干活。
忙等开始时 so_prog.txt 里只有一行 BEGIN(7 字节);忙等结束时文件已是 24 字节——多出来的正是 W_T0=2717021375 那一行。这行是工作线程写的。也就是说:主线程忙着算自己的 200 ms 时,工作线程已经把它的启动时间戳写进了文件。
证据 ③:两者在抢 CPU,所以任务被拖长了。
同一个 200 万次忙循环,独占(同步调用)时耗时 531 ms;这次与主线程并行时,从工作线程自己的时间戳看,W_T0=2717021375 → W_T1=2717022187,耗时 812 ms。多出来的 280 ms 就是两个线程争抢同一颗 CPU 的代价。
证据 ④(用另一组探针把时间窗卡到毫秒):
那组探针里,主线程忙等结束时的时间戳是 2716283937(PB 12.5),而工作线程的进度记录正是 W_PROG=500000@2716283937——主线程忙等结束的那一刻,恰好就是工作线程推到 50 万步的那一刻(PB 10 对应 2716293234,落在 W_T0=2716292921 与 W_T1=2716293734 之间)。两条时间线在同一个数轴上交替出现,并发成立。
七、★★★ 真正的"坑"在取值:跨会话读属性是阻塞的
上面 li_pick = 3 的输出里,最值得盯的是这两个数字:
- progress=2000000 read_block_ms=609
复制代码
主线程在忙等 200 ms 之后才去读 iuo_w.il_progress,这一次读取花了 609 ms(PB 10 是 547 ms)。而且读回来的不是中途进度,而是最终值 2000000。
为什么?
因为 il_progress 是另一个会话里的实例变量。主线程读它,要走跨会话的通道;而这个通道会等到那个对象"空闲"(当前调用结束)才把值交回来。所以:
结论:跨会话读实例变量 = 一次隐式的"等它干完"(阻塞)。 用读属性的方式做"进度轮询",等于每次轮询都把主线程挂起到任务结束——轮询毫无意义,界面照样卡死。
对照数据:任务跑完之后再去读同一个属性,耗时 0 ms(两组探针都是 0)。也就是说阻塞只发生在对方忙碌时,对方空闲时读取是即时的。
7.1 那"边跑边看进度"到底怎么做?
既然不能读属性,就把结果通道换成不经过 PB 会话的东西:
| 通道 | 可行性 | 说明 | | 直接读共享对象实例变量 | ❌ | 本次实测确认会阻塞到任务结束,拿不到中途值 | | 写/读文件 | ✅ | 两个会话都能访问文件系统。本次探针就是靠 so_prog.txt 追加时间戳行,拿到了完整的中途进度 | | 写/读数据库表 | ✅ | 同理,写一张进度表,主线程轮询表 | | 主线程 PostEvent 到窗口,由工作线程反向通知 | ⚠️ | 需要共享对象持有窗口引用,且窗口侧的 UI 操作也要走消息队列,注意可重入性 |
下面是本次实机验证过的"文件通道"写法。工作线程侧就是 4.2 里的 of_log(往 so_prog.txt 追加带时间戳的行),主线程侧用一个独立的读取方法(4.3 里的 of_read_progress_log):
- public function string of_read_progress_log ();
- //把进度日志整份读出来(主线程侧的「文件通道」:不经过共享对象,因此不会阻塞)
- //注意:别用 FileRead 逐行读中文文本;本日志是纯 ASCII,这里可以放心逐行读
- string ls_all
- string ls_line
- int li_f
- ls_all = ''
- li_f = FileOpen('so_prog.txt', LineMode!, Read!, LockRead!)
- if li_f > 0 then
- do while FileRead(li_f, ls_line) <> -100
- ls_all = ls_all + ls_line + '~r~n'
- loop
- FileClose(li_f)
- end if
- return ls_all
- end function
复制代码
在窗口上这样用(Timer 事件或按钮里都行):
- // 事件:cb_show_progress :: clicked
- // 输入:共享对象宿主
- nvo_so_article lart
- string ls_all
- lart = create nvo_so_article
- // 直接把整份进度日志读出来显示;这条通道不经过共享对象,主线程不会被挂起
- ls_all = lart.of_read_progress_log()
- destroy lart
- if Len(ls_all) = 0 then
- MessageBox('后台任务进度', '暂无进度记录')
- else
- MessageBox('后台任务进度', ls_all)
- end if
复制代码
实机读回的原文(PB 12.5 那一轮):
- BEGIN
- W_T0=2717021375
- W_PROG=500000@2717021640
- W_PROG=1000000@2717021828
- W_PROG=1500000@2717022000
- W_PROG=2000000@2717022187
- W_T1=2717022187
复制代码为什么这里读文件不会卡主线程:文件读写是操作系统层面的调用,不经过 PB 的跨会话通道,工作线程写、主线程读互不阻塞(文件本身可能有共享锁的细节,用 LockWrite! / LockRead! 或 Append! 都能配合)。这也是本次探针能在主线程忙等期间、从文件里看到工作线程新写内容的原因。
八、生命周期:注销、旧引用、重新注册、析构
8.1 注销之后,手里那份引用还能用
li_pick = 4 的实测输出:
- reg1=true get=true unreg=true oldref_thread=17080 oldref_ok=true rereg=true final_unreg=true
复制代码
(PB 10:oldref_thread=24744,其余一致。)
SharedObjectUnregister('sw4') 已经返回 Success!,但随后 iuo_w.of_ping() 照样调用成功并返回了线程号 17080。
原因:注销释放的是"名字"这个登记项,实例本身由引用计数保活——你手里还握着 iuo_w 这个引用,PB 就不会销毁它。
→ 实践含义:别指望"注销一下就把后台对象清干净"。要真正释放,得先把所有引用置空(或放到作用域外),再让会话结束。
8.2 注销后同名可以立刻重新注册
实测(PB 12.5 间隔 16 ms、PB 10 间隔 0 ms):
| 动作 | PB 12.5 | PB 10 | | 注销 sw4 后立刻重新注册同名 | Success! | Success! | | 注销后等 300 ms 再注册同名 | Success! | Success! | | 换一个全新的名字注册(对照组) | Success! | Success! | | 注销后 SharedObjectGet 该名字 | SharedObjectNotExistsError! | 同 |
三组写法全部成功,说明名字在注销那一刻就立刻释放,不存在"要等一会儿才能复用"的问题。(这一条最初跑出过一次不一致的结果,后来专门加了一组探针连续复跑 6 轮才钉死,属于测量时序陷阱,不是产品行为。)
8.3 析构发生在共享会话自己的线程上
nvo_so_worker.destructor 会把销毁时的线程号追加到 so_dtor.txt。清空该文件后跑一轮完整演示(示例一~示例五 + 一次编号分发,共注册 8 个共享实例):
| 版本 | 主会话线程号 | 共享实例线程号 | 8 条析构记录的线程号 | 有主线程号吗 | | PB 12.5 | 12136 | 6900 | 6900 / 4308 / 5856 / 12308 / 20492 / 17080 / 22116 / 15616 | 没有 | | PB 10 | 12328 | 12260 | 12260 / 8688 / 23860 / 24980 / 13096 / 24744 / 9752 / 23608 | 没有 |
8 条析构记录全部发生在共享会话的线程上,主线程一次都没参与。 第一条恰好等于第一个共享实例的线程号(6900 / 12260),可以对应上。
→ 实践含义:不要把"销毁时的清理逻辑"写在主线程能感知的地方。共享对象的 destructor 里不要直接去操作主会话的窗口/数据窗口——那是在另一个会话的线程上跑的,跨会话碰 UI 对象是踩雷动作;要通知主会话就用 PostEvent 或文件/数据库这类外部通道。
8.4 目录枚举 SharedObjectDirectory
实测(示例五):
- dir_cnt=1 item1=sw5/nvo_so_worker
复制代码
两个数组按位置对应:instancenames 是实例名,classnames 是类名。多实例时的实测输出形如 sw2/nvo_so_worker、sw1/nvo_so_worker(注意顺序不保证是你注册的顺序,别依赖它)。
注意:SharedObjectDirectory 枚举的是当前进程内的登记项。上一个失败案例里,注册 nvo_no_such_class 失败后并不留下条目(dir_cnt 仍为 1),所以可以用它来"数一数当前到底挂了几个共享实例"。
九、shared variables 是"会话级",不是"进程级"
变量作用域那篇文章里讲过共享变量(shared variables)。那这个"共享"能不能跨会话共享?不能,实测:
在工作者对象里加一个 shared variables long sl_hits,每次调用 of_ping() 就 sl_hits = sl_hits + 1。
| 观察点 | PB 12.5 | PB 10 | 结论 | | 共享会话内的 sl_hits(连续调用后) | 3 → 4 → 5 | 同 | 在共享会话内正常累加 | | 主会话里 create 出来的实例 A 的 sl_hits | 0 → 1 → 2 | 同 | 与共享会话的值互不影响 | | 主会话里 create 出来的实例 B 的 sl_hits | 0 → 1 → 2 | 同 | 与 A 也互不影响(各自一份) |
结论:shared variables 只在"同一个 PB 会话"内共享。 跨会话是隔离的——这也是共享对象能在另一个线程上安全跑的前提(否则两个会话同时读写同一份变量,必然出问题)。
十、共享会话有完整的 PB 运行能力吗
有。实测在共享会话里 create datastore:
| 探针 | PB 12.5 | PB 10 | | 共享会话内 create datastore 后 IsValid | ds_ok | ds_ok |
共享会话里可以正常创建 DataStore、执行嵌入式 SQL、读写文件——它就是一套完整的 PB 运行时,只是跑在另一个线程上。这意味着"后台批量取数/加工"这条路是走得通的,只要遵守第七节的"结果通道"规则。
十一、坑清单(都是这次实测踩到的)
| # | 坑 | 实测结论与对策 | | 1 | 以为共享对象调用是异步的 | 同步阻塞。有返回值、无返回值、TriggerEvent 三种入口统一同步(531/531/750 ms)。要"不阻塞"只有 PostEvent,且只在派发那一刻不阻塞 | | 2 | 用"读实例变量"做进度轮询 | 跨会话读会阻塞到任务结束(实测 609 / 547 ms),读到的是终值。改用文件/数据库通道 | | 3 | 类名写错,盯着 CreateInstanceError 排查 | 实际报的是 SharedObjectCreatePBSessionError!;CreateInstanceError 本次一次都没触发 | | 4 | 类名写错后没处理返回值 | 后续 SharedObjectGet 会返回 SharedObjectNotExistsError!,引用保持 invalid(IsValid=false),不做判空直接调方法就会 Null object reference | | 5 | 注销后以为对象没了,立刻重新注册并"重新 create" | 旧引用仍然可用(引用计数保活)。注销只释放名字,不释放实例 | | 6 | 在共享对象的 destructor 里操作主会话的窗口对象 | 析构发生在共享会话的线程上(实测 8/8 全非主线程)。跨会话碰 UI 是雷区,改走 PostEvent 或外部通道 | | 7 | 两个不同实例以为共用一条线程 | 每个实例各有自己的线程(sw1 与 sw2 线程号实测不同),注册越多线程越多,别无限注册 | | 8 | 以为 shared variables 能跨会话传数据 | 不能,它是会话级的。跨会话传数据要走实例变量(但读会阻塞,见第 2 条)或外部通道 | | 9 | 用 boolean 接 SharedObjectRegister 返回值 | 返回值是 errorreturn 枚举,boolean 接会类型不兼容。判断成功写 ler = Success! | | 10 | 排错时想看枚举文字 | String(ler) 有效,实测返回 "SharedObjectCreatePBSessionError!" 这类字面名,直接进日志很好用 | | 11 | 在 headless / 无窗口环境靠 Yield() 让出执行权 | 实测 Yield() 返回 false(没有消息队列时让不出任何东西),协作式并发在这种环境下等于没有 | | 12 | 以为内建 Sleep 是毫秒 | 内建 Sleep(1) 实测睡了 1000 ms,单位是"秒"。要毫秒级睡眠必须自己挂 kernel32.dll 的函数 |
十二、和替代方案的选型对比
| 方案 | 真并行 | 主线程会卡吗 | 结果回传方式 | 适用 | | 分片 + Timer 事件 | 否 | 不卡(但总时长不变) | 直接在事件里更新界面 | 处理量可控、需要进度条刷新 | | Yield() 协作让出 | 否 | 不卡,但 headless 下无效(实测返回 false) | 同线程,直接赋值 | 有消息循环的桌面应用 | | SharedObjectRegister | 是 | PostEvent 派发时不卡;一旦读它的属性就卡 | 文件 / 数据库 / PostEvent 反向通知 | 桌面端后台长任务、批量计算、需要真并行 | | 自写 C++ 线程 DLL | 是 | 取决于回调设计 | 回调函数(PB 侧单入口) | 高频、低延迟、需要原生能力 | | PBIDEA uo_thread 等第三方封装 | 是 | 取决于封装 | 取决于封装 | 已在使用 PBIDEA 生态的项目(本文不展开,见 PBIDEA 系列目录) |
一句话选型口诀:
- 只要"界面别冻住、跑完给我个结果" → 共享对象 + PostEvent 派发 + 文件/数据库通道回结果;
- 要中途实时进度条 → 还是共享对象,但进度必须走文件/数据库,别读属性;
- 只是想让界面刷新一下 → Timer 或 Yield(),不必上共享对象;
- 要每秒几千次的小回调 → 共享对象不合适(跨会话开销摆在那儿),考虑原生 DLL。
十三、PB12.5 差异
PB 12.5 与 PB 10 行为一致。
判据不是"跑通了就算",而是逐行比对两版诊断输出:把两版完整运行的诊断文本与进度日志去掉所有数字后逐行 diff,差异行数 = 0(示例工程 6 行诊断 + 7 行进度日志;深挖用的时序探针工程 134 行诊断,同样 0 差异)。也就是说线程关系、返回枚举、调用时序、阻塞行为、生命周期规则、shared variables 隔离范围、共享会话能力,两个版本完全同构。读数上的差异只有绝对耗时的量级(PB 12.5 的循环略慢一些,例如同一忙循环 531 ms vs 484 ms),属于运行时性能差异,不是语义差异。
十四、实机验证情况
本次文章的示例代码(nvo_so_worker + nvo_so_article 两个对象)与用于深挖时序的探针对象,均在 PB 10 与 PB 12.5 双版本完成下列验证:
| 验证项 | PB 12.5 | PB 10 | | pbl import 导入两个 .sru(GBK / CRLF / 无 BOM) | 0 错误 ×2 | 0 错误 ×2 | | build rebuild --type full 全量编译 | 0 错误 | 0 错误 | | 示例代码运行断言(.pbtest,28 条硬断言) | PASS 28/28(连跑 3 轮全 PASS) | PASS 28/28(连跑 3 轮全 PASS) | | 时序探针运行断言(31 条硬断言) | PASS 31/31(连跑 3 轮) | PASS 31/31(连跑 3 轮) | | 源码静态检查(块结构 / 声明与实现配对 / 内置函数核对) | 0 错误 0 提醒 | — | | 源码编码检查(BOM / CRLF / 裸 LF / 导出头 / GBK 可解码) | bad = 0 | — |
工程文件清单(可复现):
| 文件 | 说明 | | nvo_so_worker.sru | 后台工作者(两版共用同一份源码,未做任何版本分支) | | nvo_so_article.sru | 调用方封装(五组演示方法) | | article45.pbtest | 自检脚本(28 条断言,输出 diag_art45.txt 与 HIT 计数) | | 探针对象 + 探针脚本 | 用于时序深挖(线程身份、阻塞时长、生命周期、目录枚举) |
说明:文章正文里的所有数字都取自这两套工程的真实输出文件(诊断文本、进度日志、析构日志),未做任何加工或补齐。
十五、附件:PB10 兼容版 PBL 源码包
见本帖回复,附件内含:
- PB 10 基准可直接导入的 PBL(含 nvo_so_worker、nvo_so_article 与探针对象);
- 两个对象的 .sru 源码(GBK + CRLF + 无 BOM,可直接 LibraryImport);
- 自检脚本与运行说明(含两个版本各自的编译/运行命令与预期输出)。
导入步骤:新建空 PBL → 导入 nvo_so_worker.sru → 再导入 nvo_so_article.sru(顺序不能颠倒,引用方必须后导入,否则报 C0001 Illegal data type)→ 全量重建 → 按第四节 4.1 的操作步骤挂到按钮上运行。
十六、小结
- PB 语言层唯一的官方并发手段是共享对象,一共四个函数;
- 它新开的是会话 + 线程,所以线程号一定和主线程不同,且每个实例各有自己的线程;
- 成员函数调用是同步的——"没有返回值"、"TriggerEvent"都不能让它变异步;
- 只有 PostEvent 派发那一刻是 0 ms 返回,主线程能在这段时间里干自己的活(实测 200 ms 内跑了 9 万次迭代);
- 跨会话读属性 = 阻塞到对方空闲(实测 609 / 547 ms),所以进度必须走文件或数据库;
- 注销只释放名字,旧引用仍可用(引用计数保活),同名可立刻重注册;
- 析构发生在共享会话自己的线程上,主线程不参与(实测 8/8);
- shared variables 是会话级的,跨会话隔离;
- PB 10 与 PB 12.5 行为完全一致(134 行诊断输出去数字后逐行 0 差异)。
把这九条记住,共享对象就不会再"玄学"了。 |