资讯动态

tqsdk线程退出难题:优雅关闭策略与实战模板

发布时间:2026/10/13 17:21:01 来源:尧图企业网站定制
用 tqsdk 写量化策略时如果你没亲手被close卡住一次都不好意思说自己真正调试过这个库。我自己的模拟盘策略跑得好好的一到手动停止程序CtrlC 按下去没反应或者关掉界面以后终端里还挂着一个占着 CPU 的 python 进程这种事碰得多了就明白问题不是行情算不出来而是线程没有按预期退出。这篇文章不说废话直接围绕“tqsdk 线程退出问题怎么解决”来聊。我会把TqApi.close()到底做了什么讲清楚再把多线程场景下最容易踩的坑梳理出来最后给一份可以直接抄作业的关闭模板。适合所有用 tqsdk 跑过程序化策略、又不想被僵尸进程折磨的人。1. 先搞懂 TqApi 的生命周期close 到底关闭了什么很多人第一次接触 tqsdk 的时候都会先照着示例写一段代码from tqsdk import TqApi, TqAuth api TqApi(authTqAuth(你的账号, 你的密码)) quote api.get_quote(SHFE.rb2510) while True: api.wait_update() print(quote.last_price)这段代码能跑能收到行情看起来一切正常。但如果你在这个while True循环外面想通过关闭窗口、按 CtrlC 或者调用api.close()来结束程序就进入了第一个雷区你以为 close 只是“关掉”一个对象但实际上它是在收拾一个由行情连接、内部事件循环、后台工作线程共同组成的复杂状态机。1.1 TqApi 内部不是单纯的“请求-响应”tqsdk 的工作方式本质上是往行情服务器和交易服务器建立长连接服务器有变化就主动推送过来。你的策略代码调用api.wait_update()其实是在等待并处理这些推送事件。于是 TqApi 实例一创建后台就会自动拉起一些线程和异步任务。这些线程不属于你的策略线程它们是库自己管理的内部线程。你写的while True只是消费者真正干活的数据推送是在后台完成的。这就引出一个关键问题程序退出时操作系统并不会知道你创建过线程如果你不主动关闭那些后台线程就会一直活着。哪怕你的策略循环已经退出只要内部线程没释放python 进程就会挂在你眼前表现为终端失去响应、进程列表里留着python.exe、电脑风扇一直转。1.2 close 不是“关一下”那么简单api.close()解决了这件事但它不是一条简单的代码。它内部要做的事情至少有断开行情 WebSocket 连接、退出内部事件循环、清理订阅关系、注销账号登录状态、释放线程句柄。尤其要注意如果代码里同时创建了多个 TqApi比如你在做多账户、多策略的并发程序每个 api 都会拉起一套独立的后台资源。只关一个其他依然活着进程照样退不干净。另外close还牵涉到一个幂等问题。有的开发者怕退出不彻底在代码里调用了两次api.close()结果第二次调用直接抛出异常。原因倒也好理解第一次 close 时事件循环已经关闭第二次再关闭一个已经关闭的东西自然就会报错。所以后面我会专门讲一个“只关一次”的封装避免这种情况。1.3 不调用 close会发生什么我见过不少同学最初写策略时根本不调用 close理由很简单“反正程序跑完了就退出管它呢。”这话在脚本型程序里勉强能成立。如果你执行一个一次性分析脚本python 解释器把所有代码跑完进程结束后台线程随之被系统回收。但量化策略很少是一次性脚本。你的程序里面通常有一个while True循环等待行情这个循环本身就不会结束进程自然也就不会自己消失。你按 CtrlC 的时候主线程收到中断信号但如果内部线程不接受这个信号程序就卡住了。所以close 不是可写可不写的收尾动作它是整个策略运行流程的一部分。什么时候 close、在哪一行 close、用什么方式 close直接决定程序能不能优雅退出。2. 线程场景拆开看谁在等谁线程退出问题表面上是“进程不退”本质上是“线程之间没有协作好”。要解决 close 问题先得弄清楚你的程序里到底有几个线程在跑、各自在等什么。2.1 策略主线程的等待逻辑最常见的策略线程长这样api TqApi(authTqAuth(你的账号, 你的密码)) quote api.get_quote(SHFE.rb2510) try: while True: api.wait_update() print(quote.last_price) except KeyboardInterrupt: print(收到中断) finally: api.close()这里把 close 放在 finally 里看似没问题。但你有没有想过如果api.wait_update()一直阻塞在那里而 CtrlC 的信号并没有中断它那 finally 里的 close 永远执行不到这种情况在实际中很常见按了 CtrlC屏幕上没反应再按两下倒是退了但进程退出得并不干净甚至带着一堆线程错误。2.2 后台数据线程与主线程的错位还有一类程序会把行情更新和数据落库分开单独开一个线程写数据库再开一个线程跑策略。比如这样写def write_data(quote): while True: print(f写入数据库: {quote.last_price})然后主线程跑策略循环后台线程写数据。等到关闭时主线程把 api 关了但写数据线程还在那个while True里转它读到的 quote 对象已经变成无效状态轻则打印一堆无意义的数据重则直接抛异常。这就是经典的“谁在等谁”问题。主线程认为关闭已经完成后台线程还在等着下一次更新。你真正要做的是让所有线程在退出信号出现时协同停止而不是只关掉某一个对象。2.3 行情推送线程与用户线程的区别再往深一层说tqsdk 内部线程负责接收行情推送你的策略线程负责处理行情。两者是生产者与消费者的关系。了解这一点对解决 close 问题特别关键。生产者的生命周期由 api 控制你调用api.close()生产者就停止生产。消费者的生命周期由你自己的循环控制循环退出消费者就结束。如果你先让消费者退出再关闭生产者顺序是对的。但如果顺序反过来你先关了生产者消费者还在那等下一杯水就会出问题。我在实际项目中总结的关闭顺序是先停止策略逻辑再退出循环然后再调用api.close()。这三步缺一不可。不要以为在 finally 里调一个 close 就能搞定一切前面还有一个线程在等待闭循环没退close 做了也白做。3. 实战一套能稳定退出的线程模板聊完了原理接下来给一套我实测过很多次的退出方案。这套方案的核心是主线程负责捕获退出信号策略线程负责执行行情循环所有线程通过一个threading.Event协同退出。3.1 第一步用事件对象作为退出信号import threading stop_event threading.Event()这个事件对象是所有线程的“停战协议”。主线程在收到 CtrlC 或者终止信号时调用stop_event.set()。所有子线程在自己循环里检查stop_event.is_set()一旦发现被设置就退出循环。不要用全局布尔变量去传递退出状态。Python 里全局变量在线程之间不是线程安全的读写之间可能出现不一致。用threading.Event是标准做法既简单又可靠。3.2 第二步信号处理与策略线程分开重点关注一点signal.signal只能在主线程里设置。所以你在策略线程内部设置信号处理函数往往会失败或者无效。正确的做法是主线程设置信号处理策略线程跑真正的循环。import threading import signal from tqsdk import TqApi, TqAuth stop_event threading.Event() def handle_signal(signum, frame): print(收到退出信号正在准备退出...) stop_event.set() signal.signal(signal.SIGINT, handle_signal) signal.signal(signal.SIGTERM, handle_signal) def run_strategy(): api TqApi(authTqAuth(你的账号, 你的密码)) quote api.get_quote(SHFE.rb2510) try: while not stop_event.is_set(): api.wait_update() print(f最新价: {quote.last_price}) finally: api.close() print(api 已关闭) thread threading.Thread(targetrun_strategy) thread.start() thread.join() print(程序已完全退出)这段代码踩住了三个关键点信号在主线程序处理不会出现 CtrlC 没反应的问题。策略线程循环每次wait_update()后会检查事件状态一旦被设置循环自然退出。api.close()放在finally里无论正常退出还是异常中断都会执行关闭操作。有人会问“wait_update()如果一直阻塞怎么办那不就永远也检查不到stop_event了吗”这种情况确实存在。所以我在下面补充一个 backlog 更长、也更稳妥的做法。3.3 第三步给策略循环加一个最长等待时间如果你担心wait_update()阻塞时间太长可以让循环周期性地检查退出事件import time def run_strategy(): api TqApi(authTqAuth(你的账号, 你的密码)) quote api.get_quote(SHFE.rb2510) try: while not stop_event.is_set(): api.wait_update(10) # 最长阻塞 10 秒 print(f最新价: {quote.last_price}) finally: api.close() print(api 已关闭)有些版本的 tqsdk 支持给wait_update传入超时时间参数。这样就算行情一直没推送循环也会每隔一段时间醒来一次检查退出标志。这样 CtrlC 的反馈延迟就不会超过超时时间用户体验会好很多。如果你的 tqsdk 版本不支持wait_update(10)这种写法也可以在主线程里用thread.join(timeout1)配合循环等待效果差不多。重点是不要只用死循环和wait_update()要留出检查退出信号的窗口。3.4 多策略、多账户场景下的退出编排如果你开了多个线程每个线程跑一个账户或者一个策略不能只做一个threading.Event就完事。这里还有一个更隐蔽的坑每个线程最好不要共享同一个 TqApi。tqsdk 的 api 对象不是标准的线程安全对象。多个线程同时调用同一个 api 的wait_update()轻则数据错乱重则不断抛异常。我见过有同学把两个策略放在两个线程里共用一个 api然后跑不到五分钟就崩一次。后来改成各自单独创建 api问题立刻消失。多线程分别创建 api 的正确姿势是这样的accounts [ {账号1: 密码1}, {账号2: 密码2}, ] def run_account(account_id, password): api TqApi(authTqAuth(account_id, password)) try: quote api.get_quote(SHFE.rb2510) while not stop_event.is_set(): api.wait_update() print(f{account_id} 最新价: {quote.last_price}) finally: api.close() threads [] for account_id, password in accounts: t threading.Thread(targetrun_account, args(account_id, password)) t.start() threads.append(t) for t in threads: t.join()每个线程拥有自己的 api每个 api 拥有自己的后台线程和行情订阅。退出时stop_event 一设置所有策略线程都会退出循环各自调用 close再一起退出。这样你就不需要手动一个一个去关也避免了多个线程操作同一个 api 造成的并发冲突。4. 多线程场景下的 close 陷阱与对策实际操作中close 的坑不只在“退出顺序”上还有一批异常和诡异现象很容易让人误判成行情服务器或者网络问题。我整理几个典型场景。4.1 常见的 close 异常和处理思路症状可能原因解决方案CtrlC 没反应终端卡住信号处理没有在主线程设置或者 wait_update 阻塞太深用 threading.Event 配合超时机制传递退出信号进程退出了线程但 python 进程还挂着某个 TqApi 没有调用 close每个 api 都放 finally 里调用 closeclose 的时候抛 RuntimeError: Event loop is closed重复关闭了 api或关闭顺序不对做成幂等关闭避免二次调用某个线程拿到了 stop 信号但另一个线程还在打印行情只退了策略线程没有通知所有后台线程用同一个 Event 通知所有线程退出再 join 等待全部结束界面关闭后程序自动崩溃但崩溃信息不明显有线程在 api 关闭后继续访问 quote 对象确保所有对 quote 的访问都在 stop 信号确认之后再退出这张表里的每一条我都不同程度地踩过。尤其“close 抛 RuntimeError”这个问题很多人以为是 tqsdk 的 bug其实大部分是自己代码里 close 被调用了两次或者闭包线程和外层线程之间没有协调好。4.2 二次 close 问题的幂等处理如果你在一个多线程系统里关闭逻辑可能由不同模块触发比如风控模块要关、用户手动关、主线程退出时也要关那就要小心 close 被多调的问题。我比较推荐做一个简单的关闭封装把 close 的幂等问题挡在外面import threading class SafeTqApi: def __init__(self, user, pwd): self.api None self._lock threading.Lock() def start(self): self.api TqApi(authTqAuth(user, pwd)) def close(self): with self._lock: if self.api is not None: try: self.api.close() finally: self.api None这个封装的逻辑很简单内部用锁保护 close 操作只有第一次会真正调用api.close()后面的调用都会走空分支。你可以放心地把它放在多个位置调用不用再担心二次关闭的异常。有人会觉得多此一举但在多线程程序里关闭逻辑往往不只是你一个人写。你自己知道要 close 一次另一个负责风控的同事不一定知道。做一个安全的封装比事后排查异常要省心得多。4.3 daemon 线程不要乱用网上有些解决方案会让线程设置成daemonTrue用主线程退出时强行杀死子线程的方式来解决问题。这个方式我不推荐。daemon 线程设计本身是合理的它适合那些非核心的、可随时中断的辅助任务。但你的策略线程包含行情查询和交易操作如果被强杀可能正处在写入状态或下单状态容易留下脏数据、遗漏撤单甚至导致仓位状态不一致。而且 daemon 线程还有一个特点当主线程退出时所有 daemon 线程都会被强制终止。这意味着你精心设计的 close 流程可能根本执行不到一半就被掐断。如果你希望 close 能正常把仓位、数据、连接都收尾就不要把策略线程设成 daemon老老实实用停止事件配合 join。4.4 调试思路打开 debug 看看关闭过程排查线程退出问题最怕的是你对着一个没有输出的黑框猜。tqsdk 的TqApi是支持调试模式的如果遇到诡异行为可以直接把 debug 打开看它内部到底在干什么。api TqApi(authTqAuth(你的账号, 你的密码), debugTrue)打开 debug 之后日志里会输出连接建立、数据推送、关闭等环节的详细情况。比如你发现流程走到某一步就停住不动就能很直观地知道卡在哪个连接、哪个订阅上而不是毫无头绪地检查自己的循环代码。不过 debug 模式下日志量会很大不要在正常的生产环境里一直开着。我是建议在本地排查问题时临时打开定位到原因之后立刻关掉。5. 关于退出逻辑我的几条实操经验聊到这儿代码和坑都摆得差不多了。最后再分享几条实战里总结出来的经验不一定有标准答案但对你做自己的退出设计会有帮助。5.1 不要只依赖 finally 里的 close很多人的关闭逻辑是写在finally里然后就不管了。这个习惯本身没错但对线程程序来说不够。因为finally只会保证“你的策略线程”关闭 api它不保证“其他线程”能感知退出信号。最稳的做法是像前面那样设置一个统一的事件对象让所有线程在任何地方都能检查退出状态。5.2 关闭前想清楚仓位和订单状态如果你跑的模拟盘还开着仓位程序退出时突然关掉行情连接一些未完成的挂单就再也看不到状态了。虽然 tqsdk 的账户信息在下次启动时可以重新同步但如果你在退出前本来应该撤单、应该平仓那这个操作一定不能省。我的习惯是在触发关闭信号之后、api.close()之前加一个“退出前处理”的环节比如打印当前持仓、记录挂单、撤销超时未成交订单。这样可以防止跑一段时间后想复盘却发现退出时留下了不该存在的挂单。5.3 测试退出逻辑也算测试的一部分很多人只测试策略逻辑不测试“退出逻辑”。我建议在模拟盘阶段多按几次 CtrlC多模拟几次异常中断看看是不是每次都能干净退出。如果每退出一次终端里就残留一个进程长期积累会很痛苦。你也可以写一个简单的脚本启动策略后等待几秒然后自动触发退出信号检查进程是否正常结束。这样退出逻辑也能自动化回归而不是每次手动去验证。5.4 线程数量越多退出信号越要统一最后这点是给做多策略、多账户的同学的。线程越多每个线程的退出点越散越需要统一管理。我实际项目中会维护一个所有线程对象的列表退出时统一发信号再统一 join。千万不要在某个子线程内部直接调用os._exit或者sys.exit这种强制退出方式会绕过所有清理流程非常容易把状态搞坏。退出问题看起来是个小问题但到了多线程、多账户场景下处理不好就是一个持续消耗你精力的隐形负担。把关闭机制当成策略的一部分来设计系统地做会比出问题之后再补补丁要舒服得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑