☰
tqsdk线程退出难题:优雅关闭策略与实战模板
2026/10/13 17:21:00 网站建设 项目流程

用 tqsdk 写量化策略时,如果你没亲手被close卡住一次,都不好意思说自己真正调试过这个库。我自己的模拟盘策略跑得好好的,一到手动停止程序,Ctrl+C 按下去没反应,或者关掉界面以后终端里还挂着一个占着 CPU 的 python 进程,这种事碰得多了就明白,问题不是行情算不出来,而是线程没有按预期退出。

这篇文章不说废话,直接围绕“tqsdk 线程退出问题怎么解决”来聊。我会把TqApi.close()到底做了什么讲清楚,再把多线程场景下最容易踩的坑梳理出来,最后给一份可以直接抄作业的关闭模板。适合所有用 tqsdk 跑过程序化策略、又不想被僵尸进程折磨的人。

1. 先搞懂 TqApi 的生命周期:close 到底关闭了什么

很多人第一次接触 tqsdk 的时候,都会先照着示例写一段代码:

from tqsdk import TqApi, TqAuth api = TqApi(auth=TqAuth("你的账号", "你的密码")) quote = api.get_quote("SHFE.rb2510") while True: api.wait_update() print(quote.last_price)

这段代码能跑,能收到行情,看起来一切正常。但如果你在这个while True循环外面想通过关闭窗口、按 Ctrl+C 或者调用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循环等待行情,这个循环本身就不会结束,进程自然也就不会自己消失。你按 Ctrl+C 的时候,主线程收到中断信号,但如果内部线程不接受这个信号,程序就卡住了。

所以,close 不是可写可不写的收尾动作,它是整个策略运行流程的一部分。什么时候 close、在哪一行 close、用什么方式 close,直接决定程序能不能优雅退出。

2. 线程场景拆开看:谁在等谁

线程退出问题,表面上是“进程不退”,本质上是“线程之间没有协作好”。要解决 close 问题,先得弄清楚你的程序里到底有几个线程在跑、各自在等什么。

2.1 策略主线程的等待逻辑

最常见的策略线程长这样:

api = TqApi(auth=TqAuth("你的账号", "你的密码")) 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()一直阻塞在那里,而 Ctrl+C 的信号并没有中断它,那 finally 里的 close 永远执行不到?这种情况在实际中很常见:按了 Ctrl+C,屏幕上没反应,再按两下倒是退了,但进程退出得并不干净,甚至带着一堆线程错误。

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()

这个事件对象是所有线程的“停战协议”。主线程在收到 Ctrl+C 或者终止信号时,调用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(auth=TqAuth("你的账号", "你的密码")) 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(target=run_strategy) thread.start() thread.join() print("程序已完全退出")

这段代码踩住了三个关键点:

  • 信号在主线程序处理,不会出现 Ctrl+C 没反应的问题。
  • 策略线程循环每次wait_update()后会检查事件状态,一旦被设置,循环自然退出。
  • api.close()放在finally里,无论正常退出还是异常中断,都会执行关闭操作。

有人会问:“wait_update()如果一直阻塞怎么办?那不就永远也检查不到stop_event了吗?”这种情况确实存在。所以我在下面补充一个 backlog 更长、也更稳妥的做法。

3.3 第三步:给策略循环加一个最长等待时间

如果你担心wait_update()阻塞时间太长,可以让循环周期性地检查退出事件:

import time def run_strategy(): api = TqApi(auth=TqAuth("你的账号", "你的密码")) 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传入超时时间参数。这样就算行情一直没推送,循环也会每隔一段时间醒来一次,检查退出标志。这样 Ctrl+C 的反馈延迟就不会超过超时时间,用户体验会好很多。

如果你的 tqsdk 版本不支持wait_update(10)这种写法,也可以在主线程里用thread.join(timeout=1)配合循环等待,效果差不多。重点是:不要只用死循环和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(auth=TqAuth(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(target=run_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 异常和处理思路

症状可能原因解决方案
Ctrl+C 没反应,终端卡住信号处理没有在主线程设置,或者 wait_update 阻塞太深用 threading.Event 配合超时机制传递退出信号
进程退出了线程,但 python 进程还挂着某个 TqApi 没有调用 close每个 api 都放 finally 里调用 close
close 的时候抛 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(auth=TqAuth(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 线程不要乱用

网上有些解决方案会让线程设置成daemon=True,用主线程退出时强行杀死子线程的方式来解决问题。这个方式我不推荐。

daemon 线程设计本身是合理的,它适合那些非核心的、可随时中断的辅助任务。但你的策略线程包含行情查询和交易操作,如果被强杀,可能正处在写入状态或下单状态,容易留下脏数据、遗漏撤单,甚至导致仓位状态不一致。

而且 daemon 线程还有一个特点:当主线程退出时,所有 daemon 线程都会被强制终止。这意味着你精心设计的 close 流程可能根本执行不到一半就被掐断。如果你希望 close 能正常把仓位、数据、连接都收尾,就不要把策略线程设成 daemon,老老实实用停止事件配合 join。

4.4 调试思路:打开 debug 看看关闭过程

排查线程退出问题,最怕的是你对着一个没有输出的黑框猜。tqsdk 的TqApi是支持调试模式的,如果遇到诡异行为,可以直接把 debug 打开,看它内部到底在干什么。

api = TqApi(auth=TqAuth("你的账号", "你的密码"), debug=True)

打开 debug 之后,日志里会输出连接建立、数据推送、关闭等环节的详细情况。比如你发现流程走到某一步就停住不动,就能很直观地知道卡在哪个连接、哪个订阅上,而不是毫无头绪地检查自己的循环代码。

不过 debug 模式下日志量会很大,不要在正常的生产环境里一直开着。我是建议在本地排查问题时临时打开,定位到原因之后立刻关掉。

5. 关于退出逻辑,我的几条实操经验

聊到这儿,代码和坑都摆得差不多了。最后再分享几条实战里总结出来的经验,不一定有标准答案,但对你做自己的退出设计会有帮助。

5.1 不要只依赖 finally 里的 close

很多人的关闭逻辑是写在finally里,然后就不管了。这个习惯本身没错,但对线程程序来说不够。因为finally只会保证“你的策略线程”关闭 api,它不保证“其他线程”能感知退出信号。最稳的做法是像前面那样,设置一个统一的事件对象,让所有线程在任何地方都能检查退出状态。

5.2 关闭前想清楚仓位和订单状态

如果你跑的模拟盘还开着仓位,程序退出时突然关掉行情连接,一些未完成的挂单就再也看不到状态了。虽然 tqsdk 的账户信息在下次启动时可以重新同步,但如果你在退出前本来应该撤单、应该平仓,那这个操作一定不能省。

我的习惯是在触发关闭信号之后、api.close()之前,加一个“退出前处理”的环节,比如打印当前持仓、记录挂单、撤销超时未成交订单。这样可以防止跑一段时间后想复盘,却发现退出时留下了不该存在的挂单。

5.3 测试退出逻辑,也算测试的一部分

很多人只测试策略逻辑,不测试“退出逻辑”。我建议在模拟盘阶段,多按几次 Ctrl+C,多模拟几次异常中断,看看是不是每次都能干净退出。如果每退出一次,终端里就残留一个进程,长期积累会很痛苦。

你也可以写一个简单的脚本:启动策略后等待几秒,然后自动触发退出信号,检查进程是否正常结束。这样退出逻辑也能自动化回归,而不是每次手动去验证。

5.4 线程数量越多,退出信号越要统一

最后这点是给做多策略、多账户的同学的。线程越多,每个线程的退出点越散,越需要统一管理。我实际项目中会维护一个所有线程对象的列表,退出时统一发信号,再统一 join。千万不要在某个子线程内部直接调用os._exit或者sys.exit,这种强制退出方式会绕过所有清理流程,非常容易把状态搞坏。

退出问题看起来是个小问题,但到了多线程、多账户场景下,处理不好就是一个持续消耗你精力的隐形负担。把关闭机制当成策略的一部分来设计,系统地做,会比出问题之后再补补丁要舒服得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询