Qt - 一文理解QThread多线程(万字剖析整理)

在这里插入图片描述

为什么需要多线程

在开发存在界面交互的程序中,为了使一些耗时操作不造成卡顿;我们一般会将这些耗时操作放到子线程中进行处理,常见的如一些同步通讯。

虽然已编写过几次多线程的程序,但是每次使用都感觉心里不踏实,借用 QThread 总结一下罢。

QThread使用方法

Qt 中使用多线程,必然绕不开的是 QThread。建议先过一遍 QThread Class 文档。

文档中演示了两种使用方法:

new QThread Class & Override run()

/*------------------------------WorkerThread-----------------------------------*/
class WorkerThread : public QThread
{
    
    
    Q_OBJECT
public:
    explicit WorkerThread();
protected:
    void run();
signals:
    void resultReady(const QString &s);
};

void WorkerThread::run(){
    
    
    /* ... here is the expensive or blocking operation ... */
}

/*------------------------------MainWindow-----------------------------------*/
void MainWindow::startWorkInAThread()
{
    
    
    WorkerThread *workerThread = new WorkerThread(this);
    // Release object in workerThread
    connect(workerThread, &WorkerThread::finished, workerThread, &QObject::deleteLater);
    workerThread->start();
}

需要 注意 的:

  • run() 中未调用 exec() 开启 even loop,那么在 run() 执行结束时,线程将自动退出。

  • 该案例中,WorkerThread 存在于实例化它的旧线程中,仅有 run() 中是在子线程中执行的。我们可以通过以下代码 打印线程ID 进行验证:

    qDebug()<<"mythread QThread::currentThreadId()==" << QThread::currentThreadId();
    

这就存在一个尴尬的问题,如果在 WorkerThreadrun() 中使用了 WorkerThread 的成员变量,而且 QThread的其他方法也使用到了它,即我们从不同线程访问该成员变量,这时需要自行检查这样是否安全。

在这里插入图片描述
这个例子也说明了,QThread 实例本身并不是一个线程,正如 QThread Class 开篇点明这是一个 线程管理类

扫描二维码关注公众号,回复: 12045981 查看本文章

The QThread class provides a platform-independent way to manage threads.

注意:这种使用方法并不推荐,至于它为什么仍然出现在 QThread的文档里作为案例 “误导” 我们,这貌似是个历史问题。

Qt 4.4 版本以前的 QThread 类是个抽象类,要想编写多线程代码唯一的做法就是 继承 QThread 类。该论断在 Qt4.3QThread Class 中可以印证。

但是之后的版本中,Qt 库完善了线程的亲和性以及信号槽机制,我们有了更为优雅的使用线程的方式,即 QObject::moveToThread() 。但是即使在 2020 的今天,网上仍然有不少教程教我们使用Qt多线程的旧方法;难怪 Bradley T. Hughes2010 专门写了篇 You’re doing it wrong…,为此我只能表示:

在这里插入图片描述
在这里插入图片描述

new Object Class & moveToThread(new QThread)

下面介绍推荐做法。


/*--------------------DisconnectMonitor-------------------------*/
class DisconnectMonitor : public QObject
{
    
    
    Q_OBJECT

public:
    explicit DisconnectMonitor();

signals:
    void StartMonitor(long long hanlde);
    void StopMonitor();
	// if Controller disconnect emit this signal
    void Disconnect();

private slots:
    void slot_StartMonitor(long long hanlde);
    void slot_StopMonitor();
	// State machine
    void Monitor();

private:
    long long ControllerHanlde;
    QTimer *MonitorTimer;
};

DisconnectMonitor::DisconnectMonitor()
{
    
    
	// New a Timer monitor controller by timing
    MonitorTimer = new QTimer;

	ControllerHanlde = 0;

    connect(MonitorTimer,&QTimer::timeout,this,&DisconnectMonitor::Monitor);
    connect(this,&DisconnectMonitor::StartMonitor,this,&DisconnectMonitor::slot_StartMonitor);
    connect(this,&DisconnectMonitor::StopMonitor,this,&DisconnectMonitor::slot_StopMonitor);

    MonitorTimer->start(TAKETIME);
}

void DisconnectMonitor::Monitor(){
    
    

	// if not Controller -> return
    if(0 == ControllerHanlde){
    
    
        return;
    }
	//else Listening
	else{
    
    
		int state = IsConnect(ControllerHanlde);
		if (0 != state){
    
    
            emit Disconnect();
        }
    }
}

/*---------------------------Controller----------------------------*/
class Controller : public QObject
{
    
    
    Q_OBJECT
    QThread workerThread;
public:
    Controller() {
    
    
        DisconnectMonitor *monitor = new DisconnectMonitor;
        monitor->moveToThread(&workerThread);
        connect(workerThread, &QThread::finished, monitor, &QObject::deleteLater);
        connect(monitor,SIGNAL(Disconnect()),this,SLOT(DisconnectManage()));
        workerThread.start();
    }
    ~Controller() {
    
    
        workerThread.quit();
        workerThread.wait();
    }
private slots:
    void DisconnectManage();
};

这里通过 moveToThread()Object 对象移到到新线程中,如此一来整个 monitor 都将在子线程中运行(其实这句话是有问题的,这是一个感性的理解)。

我们在 DisconnectMonitor 中定义了一个定时器用以实现定时检测。由于不能跨线程操作DisconnectMonitor 中的定时器,我们在类创建时就开启定时器,在超时事件中实现定时监听,如果检测到设备断开了,就发送 Disconnect() 信号。

使用 movetoThread() 需要注意的是:

  • 上面的案例中,并不能认为 monitor 的控制权归属于新线程!它仍然属于主线程,正如一位博主所说【在哪里创建就属于哪里】。movetoThread()的作用是将槽函数在指定的线程中调用。仅有槽函数在指定线程中调用,包括构造函数都仍然在主线程中调用!!!

  • DisconnectMonitor 须继承自 顶层 父类 Object,否则不能移动。

  • 如果 Threadnullptr,则该对象及其子对象的所有事件处理都将停止,因为它们不再与任何线程关联。

  • 调用 movetoThread() 时,移动对象的所有计时器将被重置。 计时器首先在当前线程中停止,然后在targetThread中重新启动(以相同的间隔),这时定时器属于子线程。若在线程之间不断移动对象可能会无限期地延迟计时器事件

QObject Class特别提醒movetoThread() 是线程不安全的,它只能见一个对象“推”到另一个线程,而不能将对象从任意线程推到当前线程,除非这个对象不再与任何线程关联。

connect

connect 函数原型如下:

static QMetaObject::Connection connect(const QObject *sender, const char *signal,
                    const QObject *receiver, const char *member, Qt::ConnectionType = Qt::AutoConnection);
static QMetaObject::Connection connect(const QObject *sender, const QMetaMethod &signal,
                    const QObject *receiver, const QMetaMethod &method,
                    Qt::ConnectionType type = Qt::AutoConnection);
inline QMetaObject::Connection connect(const QObject *sender, const char *signal,
                    const char *member, Qt::ConnectionType type = Qt::AutoConnection) const;

我们经常使用 connect,但是确很少留意最后一个参数 Qt::ConnectionType

  • Qt::AutoConnection
    默认连接类型,如果信号接收方与发送方在同一个线程,则使用 Qt::DirectConnection,否则使用 Qt::QueuedConnection;连接类型在信号 发射时 决定。

  • Qt::DirectConnection
    信号所连接至的槽函数将会被立即执行,并且是在发射信号的线程;倘若槽函数执行的是耗时操作、信号由 UI线程 发射,则会 阻塞 Qt的事件循环,UI会进入 无响应状态

  • Qt::QueuedConnection
    槽函数将会在接收者的线程被执行,此种连接类型下的信号倘若被多次触发、相应的槽函数会在接收者的线程里被顺次执行相应次数;当使用 QueuedConnection 时,参数类型必须是Qt基本类型,或者使用 qRegisterMetaType() 进行注册了的自定义类型。

  • Qt::BlockingQueuedConnection
    Qt::QueuedConnection 类似,区别在于发送信号的线程在槽函数执行完毕之前一直处于阻塞状态;收发双方必须不在同一线程,否则会导致 死锁

  • Qt::UniqueConnection
    执行方式与 AutoConnection 相同,不过关联是唯一的。(如果相同两个对象,相同的信号关联到相同的槽,那么第二次 connect 将失败)

注意
如果接受者线程中有一个事件循环,那么当发送者与接受者在不同的线程中时,使用 DirectConnection 是不安全的;类似的,调用其他线程中的对象的任何函数也是不安全的。值得留意的是,QObject::connect() 函数本身是线程安全的。

事件循环

若使用默认的 run() 方法或自行调用 exec() ,则QThread将开启事件循环。QThread 同样提供了 exit() 函数和 quit() 槽。这赋予了QThread使用需要事件循环的非GUI类的能力(QTimerQTcpSocket 等)。也使得该线程可以关联任意一个线程的信号到指定线程的槽函数。如果一个线程没有开启事件循环,那么该线程中的 timeout() 将永远不会发射。

如果在一个线程中创建了OBject 对象,那么发往这个对象的事件将由该线程的事件循环进行分派。

在这里插入图片描述
我们可以手动使用 QCoreApplication::postEvent() 在任何时间先任何对象发送事件,该函数是线程安全的。

源码分析

看到这,对线程的创建尚有困惑,于是查找了一下 Qt 的源码。目前在 qthread_win.cpp 找到答案,至于程序是如何从 QThread -> qthread_win 尚不清楚。

使用时,我们均以 QThread->start() 开启线程:

/*-----------------------qthread_win.cpp---------------------------------*/
void QThread::start(Priority priority)
{
    
    
    Q_D(QThread);
    QMutexLocker locker(&d->mutex);
    if (d->isInFinish) {
    
    
        locker.unlock();
        wait();
        locker.relock();
    }

    if (d->running)
        return;
    d->running = true;
    d->finished = false;
    d->exited = false;
    d->returnCode = 0;
    d->interruptionRequested = false;
    /*
      NOTE: we create the thread in the suspended state, set the
      priority and then resume the thread.
      since threads are created with normal priority by default, we
      could get into a case where a thread (with priority less than
      NormalPriority) tries to create a new thread (also with priority
      less than NormalPriority), but the newly created thread preempts
      its 'parent' and runs at normal priority.
    */
    
    // 【1】判断当前环境,调用系统API创建线程 d->handle 为线程句柄
    #if defined(Q_CC_MSVC) && !defined(_DLL) // && !defined(Q_OS_WINRT)
    #  ifdef Q_OS_WINRT
    #    error "Microsoft documentation says this combination leaks memory every time a thread is started. " \
        "Please change your build back to -MD/-MDd or, if you understand this issue and want to continue, " \
        "edit this source file."
    #  endif
        // MSVC -MT or -MTd build
        d->handle = (Qt::HANDLE) _beginthreadex(NULL, d->stackSize, QThreadPrivate::start,
                                                this, CREATE_SUSPENDED, &(d->id));
    #else
        // MSVC -MD or -MDd or MinGW build
        d->handle = CreateThread(nullptr, d->stackSize,
                                reinterpret_cast<LPTHREAD_START_ROUTINE>(QThreadPrivate::start),
                                this, CREATE_SUSPENDED, reinterpret_cast<LPDWORD>(&d->id));
    #endif // Q_OS_WINRT
    //创建线程失败
    if (!d->handle) {
    
    
        qErrnoWarning("QThread::start: Failed to create thread");
        d->running = false;
        d->finished = true;
        return;
    }
    //优先级
    int prio;
    d->priority = priority;
    switch (d->priority) {
    
    
    case IdlePriority:
        prio = THREAD_PRIORITY_IDLE;
        break;
    case LowestPriority:
        prio = THREAD_PRIORITY_LOWEST;
        break;
    case LowPriority:
        prio = THREAD_PRIORITY_BELOW_NORMAL;
        break;
    case NormalPriority:
        prio = THREAD_PRIORITY_NORMAL;
        break;
    case HighPriority:
        prio = THREAD_PRIORITY_ABOVE_NORMAL;
        break;
    case HighestPriority:
        prio = THREAD_PRIORITY_HIGHEST;
        break;
    case TimeCriticalPriority:
        prio = THREAD_PRIORITY_TIME_CRITICAL;
        break;
    case InheritPriority:
    default:
        prio = GetThreadPriority(GetCurrentThread());
        break;
    }
    if (!SetThreadPriority(d->handle, prio)) {
    
    
        qErrnoWarning("QThread::start: Failed to set thread priority");
    }
    if (ResumeThread(d->handle) == (DWORD) -1) {
    
    
        qErrnoWarning("QThread::start: Failed to resume new thread");
    }
}

核心为【1】我们先找找 _beginthreadex 原型,这是一个 Windows 系统 API

unsigned long _beginthreadex( 
void *security,       // 安全属性,NULL为默认安全属性
unsigned stack_size,  // 指定线程堆栈的大小。如果为0,则线程堆栈大小和创建它的线程的相同。一般用0
unsigned ( __stdcall *start_address )( void * ), 
                      // 指定线程函数的地址,也就是线程调用执行的函数地址(用函数名称即可,函数名称就表示地址)
void *arglist,        // 传递给线程的参数的指针,可以通过传入对象的指针,在线程函数中再转化为对应类的指针
unsigned initflag,    // 线程初始状态,0:立即运行;CREATE_SUSPEND:suspended(悬挂)
unsigned *thrdaddr    // 用于记录线程ID的地址

对应源码食用,可发现线程函数地址为 QThreadPrivate::start,跟踪一下:

unsigned int __stdcall QT_ENSURE_STACK_ALIGNED_FOR_SSE QThreadPrivate::start(void *arg) noexcept
{
    
    
    // 强制转换
    QThread *thr = reinterpret_cast<QThread *>(arg);
    QThreadData *data = QThreadData::get2(thr);
    qt_create_tls();
    TlsSetValue(qt_current_thread_data_tls_index, data);
    data->threadId.storeRelaxed(reinterpret_cast<Qt::HANDLE>(quintptr(GetCurrentThreadId())));
    QThread::setTerminationEnabled(false);
    {
    
    
        QMutexLocker locker(&thr->d_func()->mutex);
        data->quitNow = thr->d_func()->exited;
    }
    data->ensureEventDispatcher();
#if !defined(QT_NO_DEBUG) && defined(Q_CC_MSVC) && !defined(Q_OS_WINRT)
    // sets the name of the current thread.
    QByteArray objectName = thr->objectName().toLocal8Bit();
    qt_set_thread_name(HANDLE(-1),
                       objectName.isEmpty() ?
                       thr->metaObject()->className() : objectName.constData());
#endif
    //发射 started 信号
    emit thr->started(QThread::QPrivateSignal());
    QThread::setTerminationEnabled(true);
    //调用QThread,run函数
    thr->run();
    finish(arg);
    return 0;
}

可以发现调用了 QThreadrun() 方法。

而该方法默认开启事件循环:

void QThread::run()
{
    
    
    (void) exec();
}

这样我们的线程就跑起来了。

如何正确退出线程

首先,删除 QThread 对象并不会停止其管理的线程的执行。删除正在运行的 QThread 将导致 程序奔溃。在删除 QThread 之前我们需要等待 finish 信号。

  • 对于未开启事件循环的线程,我们仅需让 run() 执行结束即可终止线程,常见的做法是通过 bool 变量进行控制。由于我们的 bool runenanble 被多线程访问,这里我们需要定义一个 QMutex 进行加锁保护。至于加锁的效率问题,网上有大佬测出大概速度会降低1.5倍(Release模式)

    void TestThread::stopThread(){
          
          
        mutex.lock();
        runenanble = false;
        mutex.unlock();
    
    }
    
    void TestThread::run(){
          
          
    
        runenanble = true;
    
        while(1){
          
          
    
            if(mutex.tryLock()){
          
          
                if(!runenable)
                    break;
                else{
          
          
                /*dosomething*/
                }
            }
        }
    }
    
  • 对于开启了事件循环的线程,正常的退出线程其实质是退出事件循环。

    • quit()/exit() + wait()
      若线程中开始开启了 EvenLoop,耗时代码执行结束后,线程并不会退出。我们可调用 quit()/exit() + wait() 实现退出。

    • terminate()+ wait()
      调用 terminate() 后,将根据操作系统的调度,线程可能立即结束也可能不会,终止之后仍需使用 wait()
      由于线程可能在任何位置终止,强制结束线程是危险的操作,可能在修改数据数据时终止,可能导致线程状态无法清除,可能导致锁异常。因此并 不推荐使用

    • finished
      仅依靠上面的方法退出线程,可能存在 内存泄漏 的情况。注意到官方案例中都使用了finished 信号:

      connect(workerThread, &WorkerThread::finished, workerThread, &QObject::deleteLater);
      

      如果类对象保存在 上,自然销毁由操作系统自动完成;如果是保存在 上,没有父对象的指针要想正常销毁,需要自行释放。
      Qt4.8 开始,我们就可以通过将 finished() 信号链接至 Object::deleteLater() 来释放刚刚结束的线程中的对象。

      上文例二的 QThread 并未 new 出来,这样在析构时就需要调用 Thread::wait(),如果是堆分配的话, 可以通过 deleteLater 来让线程自杀。

      QThread workerThread = new QThread();
      connect(workerThread, &QThread::finished, workerThread, &QObject::deleteLater);
      

注意:程序退出前,需要判断各线程是否已退出,如果不进行判断,很可能程序退出时会崩溃。如果线程的父对象是窗口对象,那么在窗体的析构函数中,还需要调用 wait() 等待线程完全结束再进行下面的析构。

堆栈大小

大多数操作系统都为线程堆栈设置了最大和最小限制。如果超出这些限制,线程将无法启动。

每个线程都有自己的栈,彼此独立,由编译器分配。一般在 Windows 的栈大小为 2M,在 Linux 下是 8M

Qt 提供了获取以及设置栈空间大小的函数:stackSize()setStackSize(uint stackSize)。其中 stackSize() 函数不是返回当前所在线程的栈大小,而是获取用 stackSize() 函数手动设置的栈大小。

优先级

没错,QThread 不再让线程间拼得你死我活,我们可以通过 setPriority() 设置线程优先级,通过 priority() 获取线程优先级。

Constant Value Description
QThread::IdlePriority 0 scheduled only when no other threads are running.
QThread::LowestPriority 1 scheduled less often than LowPriority.
QThread::LowPriority 2 scheduled less often than NormalPriority.
QThread::NormalPriority 3 the default priority of the operating system.
QThread::HighPriority 4 scheduled more often than NormalPriority.
QThread::HighestPriority 5 scheduled more often than HighPriority.
QThread::TimeCriticalPriority 6 scheduled as often as possible.
QThread::InheritPriority 7 use the same priority as the creating thread. This is the default.

此外,QThread 类还提供了 yieldCurrentThread() 静态函数,该函数是在通知操作系统“我这个线程不重要,优先处理其他线程吧”。当然,调用该函数后不会立马将 CPU 计算资源交出去,而是由操作系统决定。

QThread 类还提供了 sleep()msleep()usleep() 这三个函数,这三个函数也是在通知操作系统“在未来 time 时间内我不参与 CPU 计算”。

值得注意的是:usleep()不能保证准确性 。某些OS可能将舍入时间设置为10us/15us;在 Windows 上它将四舍五入为 1ms 的倍数。

线程间通讯

其实上文已经演示了两种方式:

  • 共享内存
    线程隶属于某一个进程,与进程内的其他线程一起共享这片地址空间。

  • 消息传递
    借助Qt的信号槽&事件循环机制。

说个题外话,在 Android 中,UI操作只能在主线程中进行。这种情况在Qt中其实类似,那么当我们子线程需要更新UI控件时怎么处理呢?很简单发送信号让主线程更新即可~

线程同步

虽然使用多线程的思想是让程序尽可能并发执行,但是总有一些时候,线程必须停止以等待其他线程。例如两个线程同时写全局变量,由于写入操作相对于CPU不具备原子性,结果通常具有不确定性。

互斥锁

QMutex,任意时刻至多有一个线程可以使用该锁,若一个线程尝试获取 mutex ,而此时 mutex 已被锁住。则这儿线程将休眠直到 mutex解锁 为止。互斥锁经常用于共享数据。

QMutex mutex;

void thread1()
{
    
    
    mutex.lock();
    //dosomething()
    mutex.unlock();
}

void thread2()
{
    
    
    mutex.lock();
    //dosomething()
    mutex.unlock();
}

读写锁

QReadWriteLock,与 QMutex 类似,不过它允许多个线程对共享数据进行读取。使用它替代 QMutex 可提高多线程程序的并发度。

QReadWriteLock lock;

void ReaderThread::run()
{
    
    
    ...
    lock.lockForRead();
    read_file();
    lock.unlock();
    ...
}

void WriterThread::run()
{
    
    
    ...
    lock.lockForWrite();
    write_file();
    lock.unlock();
    ...
}

信号量

QSemaphoreQMutex 的一般化,用于保护一定数量的相同的资源。典型的是 生成者-消费者

QSemaphore sem(5);      // sem.available() == 5

sem.acquire(3);         // sem.available() == 2
sem.acquire(2);         // sem.available() == 0
sem.release(5);         // sem.available() == 5
sem.release(5);         // sem.available() == 10

sem.tryAcquire(1);      // sem.available() == 9, returns true
sem.tryAcquire(250);    // sem.available() == 9, returns false

条件变量

QWaitCondition ,它允许一个线程在一些条件满足的情况下唤醒其他线程。

QWaitCondition Class 中列举一个接收按键并唤醒去其他线程进行处理的 demo:

forever {
    
    
    mutex.lock();
    keyPressed.wait(&mutex);
    ++count;
    mutex.unlock();

    do_something();

    mutex.lock();
    --count;
    mutex.unlock();
}

forever {
    
    
    getchar();

    mutex.lock();
    // Sleep until there are no busy worker threads
    while (count > 0) {
    
    
        mutex.unlock();
        sleep(1);
        mutex.lock();
    }
    keyPressed.wakeAll();
    mutex.unlock();
}

可重入与线程安全

在查看 Qt Class 文档时,有时候我们会看到线程安全和可重入的标记,什么是线程安全,什么是可重入?

  • 线程安全
    表示该函数可被多个线程调用,即使他们使用了共享数据,因为该共享数据的所有实例都被序列化了。

  • 可重入
    一个可重入的函数可被多个线程调用,但是只能是使用自己数据的情况下。

如果每个线程使用一个类的不同实例,该类的成员函数可以被多个线程安全地调用,那么该类被称为可重入的;如果所有线程使用该类的相同实例,该类的成员函数也可以被多个线程安全地调用,那么该类是线程安全的。

在这里插入图片描述

QObject的可重入性

QObject 是可重入的。它的大多数 非GUI子类,如 QTimerQTcpSocket 也都是可重入的,可以在多线程中使用。值得注意的是,这些类被设计成在单一线程中进行创建和使用,在一个线程中创建一个对象,然后在另一个线程中调用这个对象的一个函数是无法保证一定可以工作的。需要满足以下三个条件:

  • QObject 的子对象必须在创建它的父对象的线程中创建。这意味这不要将 QThread 对象 (this) 作为在该线程中创建的对象的父对象。
  • 事件驱动对象只能在单一线程中使用。例如:不可以在对象所在的线程以外的其他线程中启动一个定时器或连接套接字。
  • 必须保证在删除 QThread 对象以前,删除在该线程中创建的所有对象。

对于大部分 GUI类,尤其是 QWidget及其子类,都是不可重入的,我们只能在主线程中使用。QCoreApplication::exec() 也必须在主线程中调用。

开启多少个线程合理

线程的切换是要消耗系统资源的,频繁的切换线程会使性能降低。线程太少的话又不能完全发挥 CPU 的性能。

一般后端服务器都会设置最大工作线程数,不同的架构师有着不同的经验,有些业务设置为 CPU 逻辑核心数的4倍,有的甚至达到32倍

Venkat Subramaniam 博士的 《Programming Concurrency on the JVM》 这本书中提到关于最优线程数的计算,即:

线 程 数 量 = 可 用 核 心 数 / ( 1 − 阻 塞 系 数 ) 线程数量 = 可用核心数/(1 - 阻塞系数) 线=/(1)

可用核心数就是所有逻辑 CPU 的总数,这可以用 QThread::idealThreadCount() 静态函数获取,比如双核四线程的 CPU 的返回值就是4。

但是阻塞系数比较难计算,这需要用一些性能分析工具来辅助计算。如果只是粗浅的计算下线程数,最简单的办法就是 CPU 核心数 * 2 + 2 。更为精细的找到最优线程数需要不断的调整线程数量来观察系统的负载情况。

参考鸣谢

Qt Creator快速入门

Qt5.9 C++开发指南

Qt多线程编程爬坑笔记

Qt使用多线程的一些心得——1.继承QThread的多线程使用方法

Qt使用多线程的一些心得——2.继承QObject的多线程使用方法

Qt 多线程编程之敲开 QThread 类的大门

QThread源码浅析

猜你喜欢

转载自blog.csdn.net/weixin_40774605/article/details/109259653