我做自动化这些年,最大的感触是:单线程脚本跑得好好的,一改多线程就翻车,翻车方式还千奇百怪。之前我写一个易语言多线程大漠脚本,目的很简单——电脑上有十几个窗口要做同样的重复操作,逐个处理实在太慢,想多开几个线程同时跑,把任务队列分发下去,效率直接翻几倍。结果第一次跑起来,窗口没处理完,程序先假死了;第二次跑,后台窗口乱套,明明这线程该处理A窗口,结果操作到了B窗口上;第三次更离谱,整个电脑直接卡死,只能重启。
后来我在“易语言多线程大漠模板脚本延时”这条路上反复折腾,才慢慢把多线程、大漠对象、延时处理这三件事的相互关系搞明白。这篇文章就把我最终稳定使用的模板、踩过的坑、以及每个关键选择的理由都写出来,给准备做多线程自动化脚本的朋友做个参考。
1. 把单线程代码复制N份,不等于多线程
1.1 易语言多线程的基本姿势
易语言里开一个线程特别简单,核心库提供的启动线程命令,把一个子程序指针丢进去,线程就跑起来了:
.版本 2 .支持库 EThread .子程序 启动按钮_被单击 .局部变量 线程ID, 整数型 启动线程 (&工作线程, 1, 线程ID)看起来是不是很简单?很多人(包括最早的我)就这么干:把单线程的自动化代码复制几份,开四个线程,每个线程操作不同的窗口,以为这就“多线程”了。结果就是开头说的那些问题。
问题的根源在于:线程之间不是互相独立的。它们共享变量、共享程序集数据、共享窗口组件,如果不加任何保护一起去读写这些公共资源,必然产生冲突——今天你撞我一下,明天我盖你一下,程序就崩了。
多线程代码至少要理清三件事:启动、同步、退出。
启动还好说,核心就一个启动线程。同步就麻烦一点,需要理解许可证。退出更是被大多数人忽略,但多线程程序一大半的崩溃都发生在退出阶段,后面我会专门说。
1.2 许可证:保护共享资源的门锁
易语言的许可证,本质上是操作系统里的关键区(Critical Section)。它的用法很直白:
.程序集变量 任务许可证, 整数型 ' 启动时创建 任务许可证 = 创建进入许可证 () ' 访问共享数据前 进入许可区 (任务许可证) ' 这里读写共享变量 离开许可区 (任务许可证)打个比方:一个公共仓库,只有一个门,门上有一把可以锁的锁。每个人进去搬东西之前,先把锁锁上,别人在外面等着;搬完出来,把锁打开,下一个人再进。许可证就是这个“锁”,保证同一时刻只有一个线程在访问共享数据。
没有这把锁会怎样?最典型的例子就是计数。
完成数量 = 完成数量 + 1这句代码看起来天经地义,但在多线程里是有问题的。它实际上分三步:读旧值、加一、写回新值。两个线程同时执行这句时,可能都读到旧值100,各自加一后都写回101,结果完成数量变成了101而不是102。你少统计了一次还不知道怎么回事。这种问题就是竞态条件。所以凡是“读改写”的共享数据,我必须用许可证保护起来。
1.3 界面组件只在主线程摸
多线程脚本还有一个极容易踩的坑:在子线程里直接操作窗口组件。比如子线程里写:
标签1.标题 = “处理完成”这种代码十次里有九次会让程序崩溃,或者整个窗口直接未响应。原因不复杂:窗口组件属于创建它的主线程,其它线程去动它,消息处理就乱了,轻则假死,重则非法操作。
所以我最终在模板里做的方案是:子线程只修改程序集变量的值,窗口上加一个时钟,周期性地把程序集变量的内容同步到界面组件上。这样所有组件操作都收敛到主线程,程序就稳了。这个细节后面模板里会体现。
2. 大漠插件在多线程下的对象隔离
2.1 为什么不能所有线程共用一个“大漠”对象
做自动化绕不开“大漠”这类窗口操作插件——它负责找窗口、找图、模拟鼠标键盘、文字识别这些脏活累活,是脚本的核心。单线程脚本里,一般全局创建一个对象,到处调用,非常方便。
但多线程里不能这么干。大漠对象有线程亲和性,它创建在哪个线程,就主要由哪个线程去调用。多个线程同时去操作同一个对象,轻则调用返回空值、找不到窗口,重则直接报错,而且窗口识别还会互相串台。我最早觉得“共用一个对象,同时找不同的窗口,应该没冲突吧”——实际不是这么回事。第一次多线程跑起来,一晚上日志里全是“窗口句柄无效”“图片识别超时”,排查了两天才意识到是大漠对象共享出了问题。
2.2 每个工作线程创建独立的大漠对象
正确的做法是:每个工作线程启动后,在线程局部变量里创建自己的大漠对象,线程结束时释放,各线程各用各的,谁也不干扰谁。
.子程序 工作线程 .参数 线程编号, 整数型 .局部变量 大漠, 对象 .局部变量 窗口句柄, 整数型 大漠.创建 (“dm.dmsoft”, ) 大漠.数值方法 (“SetPath”, 取运行目录 () + “\dm” + 到文本 (线程编号)) ' 不断取任务并执行 判断循环首 (是否继续运行 = 真) ' 处理自动化任务 ... 判断循环尾 () 大漠.清除 ()和单线程脚本的全局对象不一样,这里是“线程启动时创建,线程退出前清除”。有个朋友跟我聊过他的脚本:线程结束后不释放对象,连续开好几个线程,内存占用越来越高,进程里对象句柄一大堆。这就是典型的内存泄漏。多线程环境里资源的创建和释放必须是配对出现的,少了一头迟早出事。
2.3 每个线程的全局路径要分开设置
大漠对象有个全局路径的概念,很多命令会基于这个路径去找图片、字库、配置文件。如果多个线程用同一个全局路径,问题倒不大;但如果你给每个线程分了不同的任务目录、不同的字库,那就要注意了——SetPath是对象级别的状态,每个线程只设置自己的路径,不要用一个全局变量去控制,因为全局变量本身就需要许可证保护。
我在模板里的习惯是,每个线程编号对应一个独立子目录,比如dm1、dm2、dm3,各线程创建对象后直接SetPath到自己目录。这样配置隔离、字库隔离、日志隔离,调试的时候也方便看是哪个线程出的问题。
3. 多线程脚本里的延时体系设计
3.1 为什么说“延时”不是随便找个命令跑一下
标题里特别提到“延时”,因为延时在多线程脚本里真的是个技术活。很多新手写的多线程循环就是:
判断循环首 (是否继续运行 = 真) 大漠.数值方法 (“FindWindow”, ...) 延时 (10) 判断循环尾 ()如果是延时(1),线程几乎不休息,一个线程就能把一个CPU核心打满。开四五个线程,整个电脑风扇直接起飞。延时不是打发时间,它是让出CPU时间片、给系统喘气的机会。
反过来,延时设得太长也有问题:窗口弹窗了、条件满足了,线程还在那儿傻睡,不能及时响应。比如脚本等待一个窗口出现,直接延时(5000),等5秒再看窗口够不够,窗口在第2秒就出现了,线程白等3秒;如果窗口一直没出现,线程一卡就是5秒,效率被拖垮。
3.2 三种延时的实际用法
在我最终的模板里,延时方案是分场景组合的:
- 循环快速轮询时:用短延时,10到20毫秒。目的是给其它线程让出CPU时间片,不至于把自己占用的核心跑满。
- 等待界面反应时:用中等延时,100到300毫秒。窗口操作之后,界面不是立刻刷新,需要给它一点反应时间。
- 等待超时判定时:用“短延时循环+总超时判断”替代一次性长延时。这种我用得最多,比如等待某个窗口出现,最多等10秒:
.局部变量 已等待毫秒, 整数型 已等待毫秒 = 0 判断循环首 (已等待毫秒 < 10000 且 是否继续运行 = 真) 窗口句柄 = 大漠.数值方法 (“FindWindow”, “”, “目标窗口”, 0) 如果真 (窗口句柄 ≠ 0) 跳出循环 () 如果真结束 延时 (50) 已等待毫秒 = 已等待毫秒 + 50 判断循环尾 ()就这么一个等待逻辑,能同时解决三个问题:不阻塞操作、可以实时响应窗口事件、还能在等待过程中检查“是否继续运行”标志来及时退出线程。大延时拆成小延时循环,是模板脚本里的核心习惯。
3.3 工作线程里延时和界面线程里延时的区别
这里区分一下:易语言核心库的延时,本质上是让当前线程进入等待状态。它放在后台工作线程里没问题,但如果放在主线程——比如直接在按钮事件里延时——界面会假死,窗口拖不动、按钮点不了。
如果想在界面线程里延时又不假死,就得用带消息处理的延时命令(社区常用模块里基本都有,叫“程序_延时”之类)。原理是在延时循环里调用API让消息分发器继续处理窗口消息,这样界面才能保持响应。
在多线程模板里,主线程不处理业务延时,工作线程用普通延时,界面刷新交给时钟,这套分工把延时的问题彻底绕开了。多线程脚本最怕的就是把界面线程和工作线程的职责搅在一起。
3.4 延时的节奏:自动化脚本的呼吸
我后来总结了一个经验:一次性的长延时是万恶之源,有条理的短延时才是稳定的基础。不只是为了响应性,也是为了降低CPU占用和整体稳定性。
比如一个自动点击操作,完整节奏是:查找窗口 → 等待50ms确认窗口句柄有效 → 点击按钮 → 等待200ms让窗口内容变化 → 识别结果 → 再决定下一步。每一步之间的延时,都应该是一个可以配置的参数,而不是写死在代码里的魔法数字。我在模板里把这些延时参数都提取成窗口输入框,让使用者可以根据实际机器的速度调整。
4. 一个可以直接套用的多线程大漠模板
4.1 模板的整体结构:任务队列+工作线程
接下来是整个模板的核心。我用的是最经典的生产者-消费者模型:主界面把一批任务放进队列,工作线程从队列里取任务,取到一个处理一个。所有线程共用一个任务数组和一个“当前任务位置”指针,谁取了一个任务,指针就往后挪一位。
.版本 2 .支持库 EThread .程序集 窗口程序集_启动窗口 .程序集变量 队列许可证, 整数型 .程序集变量 任务数组, 文本型, , "0" .程序集变量 任务位置, 整数型 .程序集变量 是否继续运行, 逻辑型 .程序集变量 完成数量, 整数型 .程序集变量 线程句柄数组, 整数型, , "0"任务数组里放的是每个窗口的标题或查找参数。取任务函数用队列许可证保护:
.子程序 取下一个任务, 文本型 .局部变量 任务, 文本型 进入许可区 (队列许可证) .如果真 (任务位置 <= 取数组成员数 (任务数组)) 任务 = 任务数组 [任务位置] 任务位置 = 任务位置 + 1 .如果真结束 离开许可区 (队列许可证) 返回 (任务)4.2 启动按钮:初始化许可证并创建工作线程
.子程序 开始按钮_被单击 .局部变量 i, 整数型 .局部变量 线程ID, 整数型 如果真 (是否继续运行 = 真) 返回 () 如果真结束 队列许可证 = 创建进入许可证 () 任务位置 = 1 完成数量 = 0 是否继续运行 = 真 ' 假设任务数组已经在窗口加载时填入 重定义数组 (线程句柄数组, 假, 线程数量) 计次循环首 (线程数量, i) 线程句柄数组 [i] = 启动线程 (&工作线程, i, 线程ID) 计次循环尾 ()注意线程数量建议不要超过CPU核心数的两倍,否则线程切换开销会抵消多线程的收益。我在模板里默认值是4,让用户自己调。
4.3 工作线程内部实现:大漠对象+任务循环
.子程序 工作线程 .参数 线程编号, 整数型 .局部变量 大漠, 对象 .局部变量 任务, 文本型 .局部变量 窗口句柄, 整数型 大漠.创建 (“dm.dmsoft”, ) 大漠.数值方法 (“SetPath”, 取运行目录 () + “\dm” + 到文本 (线程编号)) 判断循环首 (是否继续运行 = 真) 任务 = 取下一个任务 () 如果真 (任务 = “”) 延时 (20) 到循环尾 () 如果真结束 ' 执行实际自动化任务,这个子程序会使用大漠对象 执行自动化任务 (大漠, 任务) 进入许可区 (队列许可证) 完成数量 = 完成数量 + 1 离开许可区 (队列许可证) 判断循环尾 () 大漠.清除 ()这里有个关键细节:大漠对象是工作线程的局部变量,每个线程创建一份独立的。同时,每个线程取不到任务时不会空转——延时(20)让出CPU,等任务队列有新任务后再取。
4.4 停止与窗口关闭:多线程的退出机制
退出机制是整个模板里我最看重的地方。直接强制结束线程,等于把锁、对象、句柄全都扔在那里不管,后面再启动线程时各种诡异问题都会冒出来。
正确的退出方式是协作式:设置结束标志,线程自己在下一次循环判断时退出。
.子程序 停止按钮_被单击 是否继续运行 = 假 计次循环首 (取数组成员数 (线程句柄数组), i) 等待线程 (线程句柄数组 [i], -1) 计次循环尾 () 删除进入许可证 (队列许可证)窗口销毁事件里也要做同样的处理:
.子程序 __启动窗口_将被销毁 是否继续运行 = 假 计次循环首 (取数组成员数 (线程句柄数组), i) 等待线程 (线程句柄数组 [i], -1) 计次循环尾 () 删除进入许可证 (队列许可证)4.5 界面反馈:时钟负责更新进度
子线程不碰组件,进度靠程序集变量+时钟实现。我在窗口上放了一个时钟,周期500毫秒,只做一件事:把完成数量和任务总数显示到标签上。
.子程序 时钟1_周期事件 标签完成数.标题 = 到文本 (完成数量) + “ / ” + 到文本 (取数组成员数 (任务数组))这个模式看起来多绕了一步,但换来了稳定性。线程安全的核心原则就是尽量减少共享状态,共享状态最少化,能不直接在子线程碰的东西,就别碰。
5. 排错实录:多线程脚本最容易翻车的几个场景
5.1 强杀线程的代价比我预想的大
初版模板里,停止按钮我曾经直接用了强制结束线程命令。当时想得很简单:用户点停止,线程立刻干掉,干净利落。
实际跑了才知道根本不是那么回事。强制结束线程,线程持有的许可证不会自动退出,大漠对象不会自动清除,线程句柄也不会自动关闭。最直观的后果是:停止一次之后再点开始,程序直接卡死——因为新线程进入许可区时,发现锁还握在已经不存在的老线程手里,永远等不到解锁。
后来我所有模板都改成标志位协作退出:停止时设置是否继续运行=假,工作线程在循环尾自然退出。牺牲了一点“立即性”,但换来的是稳定和干净。
5.2 死锁:加锁顺序必须全局统一
许可证用得不对也会翻车。有一版脚本维护两个许可证,一个保护任务队列,一个保护计数变量。有个线程进队列取任务前,先进了计数锁,另一个线程更新计数前,先进了队列锁。结果两个线程互相等对方释放锁,程序就僵在那里,CPU占用还跑满。
这就是典型的死锁。排查了半天,最后统一了规则:所有线程加锁的顺序一致,并且尽量避免在持锁状态下调用其他可能阻塞的操作。模板里取任务时只拿队列锁,计数时只拿计数锁,搞完就放开,绝不在持锁状态里延时。
5.3 线程是否真的退出了
多线程调试,判断线程是否结束是个基本操作。很多人有这个疑问:等待线程(线程句柄, -1)到底是多久?-1表示无限等待,可以理解成“一直等到线程结束为止”。
我当时测试还碰到一个情况:线程卡在找窗口的地方一直不出来,等了很久也没退出。后来发现是执行自动化任务的子程序里有一个一次性长延时,比如延时(10000),这种长延时阻塞了退出检查。所以我在模板里把长延时全部重构成“短延时循环+标志位检查”,线程在任何位置都能及时响应退出请求。
5.4 重复点击“开始”按钮导致的重叠线程
还有一次线上跑得好好的,用户手一抖连续点了两次“开始”,结果同一个窗口被两个线程同时操作,直接把目标窗口搞乱了。这个问题的根因在UI层:开始按钮没有做互斥保护。
现在的模板里,开始按钮_被单击第一行就是如果真(是否继续运行=真) 返回(),运行期间按钮直接不响应第二次启动。同样的逻辑也用在停止按钮上,避免停止期间再次点击造成重复等待。
5.5 调试时把线程轨迹打出来
多线程调试最怕的就是“不知道现在跑到哪了”。我的习惯是在工作线程的关键位置加调试输出:
调试输出 (“线程” + 到文本 (线程编号) + “启动”) 调试输出 (“线程” + 到文本 (线程编号) + “开始处理:” + 任务) 调试输出 (“线程” + 到文本 (线程编号) + “完成:” + 任务)线上跑的时候输出到日志文件,出问题后看日志,每个线程走到哪、卡在哪一步,一目了然。比盯着窗口状态瞎猜高效得多。
最后一个经验
现在我再接一个新的自动化项目,不管是易语言还是别的工具,只要涉及多线程,第一件事永远是先写退出机制、画清楚对象生命周期、定好共享数据的锁,然后才动手写业务逻辑。顺序反了,后面一定会返工。多线程不是线程越多越好,也不是越快越好,而是稳了才算好。如果这篇里的模板能帮你少几次强制重启电脑的经历,那这时间就花得值了。