关注在 QThread 内由 Pynput 侦听器调用的线程中访问的 SQLite3 数据库连接时的竞争条件

Posted

技术标签:

【中文标题】关注在 QThread 内由 Pynput 侦听器调用的线程中访问的 SQLite3 数据库连接时的竞争条件【英文标题】:Concerned about race conditions while accessing SQLite3 database connection that's accessed in thread invoked by Pynput listener inside a QThread 【发布时间】:2018-07-10 02:43:03 【问题描述】:

我正在用 Pyside2 编写一个 Windows 应用程序。由于我使用多线程的性质,我不得不在多个线程中与同一个 Sqlite3 数据库进行交互。我创建了一个 Minimal, complete, verifiable example 几乎完全相同地复制了该问题。

问题:我目前正在使用 pynput module 在按下 PushButton 后在后台监控关键活动,而 Qt GUI 无法获得“热键组合”的焦点j" + "k"。一旦按下热键组合,就会截取屏幕截图,通过 OCR 处理图像并与 OCR 文本一起保存到数据库中。图像路径通过一系列连接的信号发送到主 GUI 线程。关键监控发生在另一个QThread,以防止关键监控和图像处理影响主Qt事件循环运行。一旦 QThread 启动并发出它的启动信号,我在 key_monitor 实例中调用 monitor_for_hot_key_combo 函数,该函数将 listener 实例化为 threading.Thread,它被分配了 key_monitor 成员函数 on_releaseon_press 作为回调,它们被称为每次按下一个键。

这就是问题所在。这些回调在与实例化类不同的线程中与image_processclass 的imageprocessing_obj 实例交互。因此,当image_process 成员函数与使用SQlite 数据库的成员函数交互时,它们在单独的线程中进行比在其中创建数据库连接。Now, SQLite "can be safely used by multiple threads 前提是 没有同时使用单个数据库连接在两个或多个线程中”。为此,您 必须将 sqlite3.connect()check_same_thread 参数设置为 False。但是,如果可能的话,我宁愿避免这种对数据库的多线程访问,以防止未定义的行为。

可能的解决方案:我一直想知道threading.ThreadQThread 这两个线程是否都不是必需的,都可以在Pynput 线程中完成。但是,我似乎无法弄清楚如何只使用 Pynput 线程,同时仍然能够将信号发送回主 Qt 事件循环。

qtui.py

from PySide2 import QtCore, QtWidgets
from PySide2.QtCore import *
import HotKeyMonitor

class Ui_Form(object):
    def __init__(self):
        self.worker = None

    def setupUi(self, Form):
        Form.setObjectName("Form")
        Form.resize(400, 300)
        self.pressbutton = QtWidgets.QPushButton(Form)
        self.pressbutton.setObjectName("PushButton")
        self.pressbutton.clicked.connect(self.RunKeyMonitor)
        self.retranslateUi(Form)
        QtCore.QMetaObject.connectSlotsByName(Form)

    def retranslateUi(self, Form):
        Form.setWindowTitle(QtWidgets.QApplication.translate("Form", "Form", None, -1))
        self.pressbutton.setText(QtWidgets.QApplication.translate("Form", "Press me", None, -1))

    def RunKeyMonitor(self):
        self.Thread_obj = QThread()
        self.HotKeyMonitor_Obj = HotKeyMonitor.key_monitor()
        self.HotKeyMonitor_Obj.moveToThread(self.Thread_obj)
        self.HotKeyMonitor_Obj.image_processed_km.connect(self.print_OCR_result)
        self.Thread_obj.started.connect(self.HotKeyMonitor_Obj.monitor_for_hotkey_combo)
        self.Thread_obj.start()

    def print_OCR_result(self, x):
        print("Slot being called to print image path string")
        print(x)
if __name__ == "__main__":
    import sys
    app = QtWidgets.QApplication(sys.argv)
    Form = QtWidgets.QWidget()
    ui = Ui_Form()
    ui.setupUi(Form)
    Form.show()
    sys.exit(app.exec_())

HotKeyMonitor.py

from pynput import keyboard
from PySide2.QtCore import QObject, Signal

import imageprocess
class key_monitor(QObject):
    image_processed_km = Signal(str)
    def __init__(self):
        super().__init__()
        self.prev_key = None
        self.listener = None
        self.imageprocessing_obj = imageprocess.image_process()
        self.imageprocessing_obj.image_processed.connect(self.image_processed_km.emit)


    def on_press(self,key):
        pass

    def on_release(self,key):
        if type(key) == keyboard._win32.KeyCode:

            if key.char.lower() == "j":
                self.prev_key = key.char.lower()
            elif key.char.lower() == "k" and self.prev_key == "j":
                print("key combination j+k pressed")
                self.prev_key = None
                self.imageprocessing_obj.process_image()
        else:
            self.prev_key = None

    def stop_monitoring(self):
        self.listener.stop()

    def monitor_for_hotkey_combo(self):
        with keyboard.Listener(on_press=self.on_press, on_release = self.on_release) as self.listener:self.listener.join()

imageprocess.py

import uuid,os,sqlite3,pytesseract
from PIL import ImageGrab
from PySide2.QtCore import QObject, Signal

class image_process(QObject):
    image_processed = Signal(str)
    def __init__(self):
        super().__init__()
        self.screenshot = None
        self.db_connection = sqlite3.connect("testdababase.db", check_same_thread=False)
        self.cursor = self.db_connection.cursor()
        self.cursor.execute("CREATE TABLE IF NOT EXISTS testdb (OCRstring text, filepath text)")

    def process_image(self):
        self.screenshot = ImageGrab.grab()
        self.screenshot_path =  os.getcwd() + "\\" + uuid.uuid4().hex + ".jpg"
        self.screenshot.save(self.screenshot_path )
        self.ocr_string = pytesseract.image_to_string(self.screenshot)
        self.cursor.execute("INSERT INTO testdb (OCRstring, filepath) VALUES (?,?)",(self.ocr_string, self.screenshot_path))
        self.image_processed.emit(self.screenshot_path)

【问题讨论】:

你说同一个Sqlite3数据库在多个线程中,但是我只看到一个线程,其他的在哪里? @eyllanescthe pynput 文档提到listner 继承自threading.thread 来源:pypi.org/project/pynput 在listener 的实例化中提到的所有回调都是从threading.Thread 调用的,如文档中所述。因此,我有一个 Qthread,您可以在主 qtgui.py 模块中看到,threading.thread 在 hotkeymonitor.py 模块中看到 好的,我明白了,但是为什么他要创建另一个线程来 pyinput 他已经在线程上运行而不阻塞 GUI? @eyllanesc 这就是我的问题所在。由于pynput监听器默认使用threading.Thread,我正在寻找一种方法来自动使用我已经为监听器创建的Qthread,以避免对SQlite数据库的多线程访问。 在 GUI 中使用线程的目的不是阻塞称为 GUI 线程的主线程,在您的情况下,我看到 2 个阻塞任务,第一个是 pyinput,但它已经在线程中执行所以这没有问题,另一个是必须在另一个线程中执行的 OCR,DB 访问没有阻塞,不消耗任何资源,因此可以在 GUI 中执行。 【参考方案1】:

首先QThread不是Qt线程,也就是说,它不是一种新类型的线程,QThread是一个管理各个平台原生线程的类。所以处理QThread的线程具有threading.Thread的相同特性。

另一方面,在 GUI 中使用线程的目标不是阻塞称为 GUI 线程的主线程,在您的 pynput 中它已经有它的线程,所以不会有任何问题。另一个阻塞的任务是 OCR,所以我们必须在一个新线程中执行它。数据库的任务并不昂贵,所以不需要创建线程。

keymonitor.py

from pynput import keyboard
import time
from PySide2 import QtCore

class KeyMonitor(QtCore.QObject):
    letterPressed = QtCore.Signal(str)

    def __init__(self, parent=None):
        super().__init__(parent)
        self.listener = keyboard.Listener(on_release = self.on_release)

    def on_release(self,key):
        if type(key) == keyboard._win32.KeyCode:
            self.letterPressed.emit(key.char.lower())

    def stop_monitoring(self):
        self.listener.stop()

    def start_monitoring(self):
        self.listener.start()

imageprocess.py

import uuid
import pytesseract

from PIL import ImageGrab

from PySide2 import QtCore

class ProcessWorker(QtCore.QObject):
    processSignal = QtCore.Signal(str, str)

    def doProcess(self):
        screenshot = ImageGrab.grab()
        screenshot_path =  QtCore.QDir.current().absoluteFilePath(uuid.uuid4().hex+".jpg")
        screenshot.save(screenshot_path )
        print("start ocr")
        ocr_string = pytesseract.image_to_string(screenshot)
        print(ocr_string, screenshot_path)
        self.processSignal.emit(ocr_string, screenshot_path)
        self.thread().quit()

ma​​in.py

from keymonitor import KeyMonitor
from imageprocess import ProcessWorker
from PySide2 import QtCore, QtWidgets

import sqlite3

class Widget(QtWidgets.QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)

        self.last_letter = ""
        self.current_letter = ""

        lay = QtWidgets.QVBoxLayout(self)
        button = QtWidgets.QPushButton("Start")
        button.clicked.connect(self.onClicked)
        lay.addWidget(button)

        self.keymonitor = KeyMonitor()
        self.keymonitor.letterPressed.connect(self.onLetterPressed)

        self.db_connection = sqlite3.connect("testdababase.db")
        self.cursor = self.db_connection.cursor()
        self.cursor.execute("CREATE TABLE IF NOT EXISTS testdb (OCRstring text, filepath text)")
        self.threads = []

    def onClicked(self):
        self.keymonitor.start_monitoring()

    def onLetterPressed(self, letter):
        if self.last_letter:
            if self.current_letter:
                self.last_letter = self.current_letter
            self.current_letter = letter
        else:
            self.last_letter = letter

        if self.last_letter == "j" and self.current_letter == "k":
            print("j+k")
            self.start_processing()

    def start_processing(self):
        thread = QtCore.QThread()
        self.worker = ProcessWorker()
        self.worker.processSignal.connect(self.onProcessSignal)
        self.worker.moveToThread(thread)
        thread.started.connect(self.worker.doProcess)
        thread.finished.connect(self.worker.deleteLater)
        thread.finished.connect(lambda th=thread: self.threads.remove(th))
        thread.start()
        self.threads.append(thread)

    def onProcessSignal(self, ocr, path):
        print(ocr, path)
        self.cursor.execute("INSERT INTO testdb (OCRstring, filepath) VALUES (?,?)",(ocr, path))
        self.db_connection.commit()

if __name__ == "__main__":
    import sys
    app = QtWidgets.QApplication(sys.argv)
    w = Widget()
    w.show()
    sys.exit(app.exec_())

【讨论】:

以上是关于关注在 QThread 内由 Pynput 侦听器调用的线程中访问的 SQLite3 数据库连接时的竞争条件的主要内容,如果未能解决你的问题,请参考以下文章

PyQt - 主要挂在 QThread

将参数传递给 pynput 侦听器

pynput.mouse 监听器不停止

使用 pynput 监听器创建键盘快捷键

比较从 pynput 返回的数据

使用 pynput 获取鼠标位置