Linux线程同步与互斥(二):条件变量与线程同步

💬 重磅来袭:上一篇解决了互斥问题——用锁保护共享数据,避免数据竞争。但实际场景往往更复杂:生产者生产数据,消费者消费数据,队列空的时候消费者要等,队列满的时候生产者要等。这种"等待某个条件满足"的场景,单靠互斥锁是做不到的。这就引出了本篇的核心——条件变量(condition variable)。条件变量让线程可以高效地等待某个条件成立,而不是傻傻地轮询检查。我们会从同步与互斥的区别讲起,深入理解条件变量的设计思想,掌握正确使用方法,最后封装成好用的工具类。

👍 点赞、收藏与分享:本篇包含大量原理图示、经典案例、使用陷阱分析,是理解线程同步的关键!如果对你有帮助,请点赞、收藏并分享!

🚀 循序渐进:从概念区分到原理剖析,再到正确使用,一步步掌握条件变量。


一、同步 vs 互斥

1.1 互斥是什么

回顾一下上一篇的内容。互斥解决的是"访问冲突"问题:

互斥(Mutual Exclusion):
- 多个线程要访问同一份数据
- 同一时刻只允许一个线程访问
- 保护临界资源,避免数据竞争

例子:
多个线程同时修改 ticket--
→ 用 mutex 保护,同一时刻只有一个线程能执行

互斥关注的是"谁先谁后",是一种资源保护机制

同步(Synchronization):
- 多个线程要协同工作
- 某些线程需要等待某个条件满足
- 一个线程的执行依赖另一个线程的结果

例子:
生产者往队列里放数据,消费者从队列里取数据
- 队列空时,消费者要等生产者生产
- 队列满时,生产者要等消费者消费

同步关注的是"协调时序",是一种协作机制

互斥是基础,同步建立在互斥之上

场景分析:
┌─────────────────────────────────────┐
│ 生产者-消费者问题                      │
├─────────────────────────────────────┤
│ 需要互斥:                            │
│   访问队列时要加锁                     │
│   防止多个线程同时操作队列              │
│                                     │
│ 需要同步:                            │
│   队列空时,消费者等待                  │
│   队列满时,生产者等待                  │
│   生产者生产后,通知消费者              │
│   消费者消费后,通知生产者              │
└─────────────────────────────────────┘

结论:
- 没有互斥,数据会乱
- 没有同步,线程不知道什么时候该干活
- 两者配合使用,才能实现正确的并发

📌 记住这个区别:互斥是"不让同时访问",同步是"让等待的线程知道可以继续了"。条件变量就是用来实现同步的工具。


二、为什么需要条件变量

2.1 轮询的问题

不用条件变量,怎么让线程等待某个条件?最简单的想法是轮询:

// 消费者线程
while (1) {
    pthread_mutex_lock(&mutex);
    if (queue.empty()) {
        pthread_mutex_unlock(&mutex);
        continue;  // 队列空,继续循环检查
    }
    
    // 队列非空,取数据
    int data = queue.front();
    queue.pop();
    pthread_mutex_unlock(&mutex);
    
    process(data);
}

这样写有三个问题:

问题1:浪费CPU
  while 循环不停地检查条件
  即使队列一直是空的,CPU也在空转
  
问题2:响应延迟
  如果循环中加 sleep(1) 来减少CPU占用
  那么生产者放入数据后,消费者最多要等1秒才能发现
  
问题3:锁竞争
  频繁加锁解锁,增加锁竞争
  影响生产者的工作效率

2.2 理想的方案

理想情况应该是这样:

消费者视角:
1. 加锁检查队列
2. 如果队列空:
   - 进入等待状态(不占用CPU)
   - 等待生产者的通知
3. 被唤醒后,继续执行
4. 取数据,解锁

生产者视角:
1. 加锁
2. 往队列放数据
3. 通知等待的消费者:"队列有数据了"
4. 解锁

关键点:
- 消费者等待时不占用CPU
- 生产者主动通知,消费者立即响应
- 不需要轮询

条件变量就是实现这个机制的工具。


三、pthread_cond 详解

3.1 基本API

3.1.1 初始化和销毁
#include <pthread.h>

// 静态初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

// 动态初始化
int pthread_cond_init(pthread_cond_t *cond, 
                      const pthread_condattr_t *attr);
// attr 传 NULL 表示默认属性

// 销毁
int pthread_cond_destroy(pthread_cond_t *cond);

返回值:成功返回0,失败返回错误号
3.1.2 等待和唤醒
// 等待条件变量
int pthread_cond_wait(pthread_cond_t *cond, 
                      pthread_mutex_t *mutex);

// 唤醒一个等待线程
int pthread_cond_signal(pthread_cond_t *cond);

// 唤醒所有等待线程
int pthread_cond_broadcast(pthread_cond_t *cond);

返回值:成功返回0,失败返回错误号

关键问题:为什么 wait 需要传入 mutex?

这是条件变量最容易让人困惑的地方,后面会详细解释。

3.2 简单示例

先看一个最简单的例子,理解基本用法:

#include <iostream>
#include <pthread.h>
#include <unistd.h>

pthread_mutex_t mutex;
pthread_cond_t cond;
bool ready = false;

void *thread1(void *arg) {
    std::cout << "thread1: 等待条件满足..." << std::endl;
    
    pthread_mutex_lock(&mutex);
    while (!ready) {
        pthread_cond_wait(&cond, &mutex);  // 等待
    }
    pthread_mutex_unlock(&mutex);
    
    std::cout << "thread1: 条件满足,继续执行!" << std::endl;
    return nullptr;
}

void *thread2(void *arg) {
    sleep(2);  // 模拟一些工作
    
    std::cout << "thread2: 准备好了,通知等待线程" << std::endl;
    
    pthread_mutex_lock(&mutex);
    ready = true;
    pthread_cond_signal(&cond);  // 唤醒等待线程
    pthread_mutex_unlock(&mutex);
    
    return nullptr;
}

int main() {
    pthread_mutex_init(&mutex, nullptr);
    pthread_cond_init(&cond, nullptr);
    
    pthread_t t1, t2;
    pthread_create(&t1, nullptr, thread1, nullptr);
    pthread_create(&t2, nullptr, thread2, nullptr);
    
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    
    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond);
    
    return 0;
}

运行结果:

thread1: 等待条件满足...
thread2: 准备好了,通知等待线程
thread1: 条件满足,继续执行!

基本流程:

  1. thread1 检查条件不满足(ready = false),调用 wait 进入等待
  2. thread2 修改条件(ready = true),调用 signal 唤醒 thread1
  3. thread1 被唤醒,继续执行

四、pthread_cond_wait 的原理

4.1 为什么 wait 需要传入 mutex

要回答这个问题,关键在于理解:

条件变量要同时解决两个问题,而这两个问题单独做都会出错。


一、消费者在条件不满足时,必须同时做到两件事

当消费者发现“队列为空”时,它必须:

1. 让出 mutex(否则生产者进不来 push)
2. 自己进入等待状态(否则只能轮询)

而且这两件事必须 紧密衔接
如果把它们拆开做,就会出问题。


二、如果不把 mutex 交给 wait,你会怎么写?

假设 pthread_cond_wait 不需要 mutex,那消费者只能自己拼逻辑。
直觉上你会想到两种写法。

❌ 写法一:拿着锁直接 wait(不会丢信号,但会死锁)

pthread_mutex_lock(&mutex);
if (queue.empty()) {
    wait(&cond);   // 睡着,但还拿着 mutex
}

结果是:

- 消费者睡眠时仍然持有 mutex
- 生产者无法获得 mutex
- 无法 push 数据
- 更不可能 signal

👉 这是死锁,问题很明显。

❌ 写法二:先解锁,再 wait(避免死锁,但会丢信号)

为了避免死锁,你自然会改成:

pthread_mutex_lock(&mutex);
if (queue.empty()) {
    pthread_mutex_unlock(&mutex);  // 先让出锁
    wait(&cond);                   // 再进入等待
    pthread_mutex_lock(&mutex);    // 醒来后重新加锁
}

这段代码看起来非常合理,但这里就出现了一个致命的时间窗口。


三、信号丢失是怎么发生的(关键时间线)

假设队列一开始是空的:

时刻  消费者线程                      生产者线程            队列状态
T1    lock(mutex)
T2    queue.empty()true
T3    unlock(mutex)
T4                                  lock(mutex)
T5                                  queue.push(1)
T6                                  signal(cond)
T7                                  unlock(mutex)
T8    wait(cond)  ← 现在才进入等待

结果:

- signal 发生时,消费者还没进入等待队列
- 条件变量不会“记住”这个 signal
- 消费者随后进入睡眠
- 队列里明明已经有数据,但消费者可能永远睡着

👉 这就叫信号丢失(lost wakeup)

关键不是“有没有加锁”,
而是:

“释放锁”和“进入等待”这两步不是原子的,中间被生产者插进来了。


四、pthread_cond_wait(cond, mutex) 到底解决了什么?

pthread_cond_wait 的真正价值在于:
它把最危险的两步合成了一个原子操作

pthread_cond_wait(cond, mutex) 保证:
1. 将当前线程加入条件变量的等待队列
2. 释放 mutex
3. 线程进入睡眠
↑
以上步骤对外不可分割

也就是说:

  • 不存在“锁已经释放,但线程还没开始等”的窗口
  • signal 要么发生在你进入等待之前(你已经在队列里)
  • 要么发生在你等待之后(自然能被唤醒)
  • 不可能被错过

总结:

wait 之所以必须传入 mutex,是因为线程在等待条件时,
必须原子地完成“释放互斥锁并进入等待队列”这两步。
如果这两步分开执行,就会在解锁与等待之间产生竞态窗口,
从而导致 signal 发生却无人接收,即“信号丢失”。


4.2 wait 的完整流程

pthread_cond_wait(&cond, &mutex) 的详细过程:

前提:调用前必须已经持有 mutex

步骤1:原子地执行以下操作
      ├─ 将当前线程放入条件变量的等待队列
      └─ 释放 mutex

步骤2:线程进入休眠状态
      └─ 不占用CPU,等待被唤醒

步骤3:被 signal 或 broadcast 唤醒

步骤4:尝试重新获取 mutex
      ├─ 如果 mutex 被其他线程持有,继续等待
      └─ 获取到 mutex 后,wait 函数返回

步骤5:返回后
      └─ 线程重新持有 mutex

流程图:

消费者线程                      生产者线程
─────────                      ─────────
lock(mutex)
  │
  ├─ while (queue.empty())
  │
  ├─ wait(cond, mutex)
  │    ├─ 加入等待队列
  │    ├─ unlock(mutex) ──────→  lock(mutex)  ← 生产者可以拿到锁了
  │    │                           │
  │    ├─ 休眠                     ├─ queue.push(data)
  │    │                           │
  │    │              ←──────────  ├─ signal(cond)  ← 唤醒消费者
  │    │                           │
  │    ├─ 被唤醒                   ├─ unlock(mutex)
  │    │                           ↓
  │    ├─ 尝试lock(mutex)
  │    │   (等待生产者释放)
  │    │
  │    └─ lock(mutex)成功
  │         wait返回
  │
  ├─ queue.front()
  ├─ queue.pop()
  │
unlock(mutex)

📌 核心理解:wait 调用前后,都是持有 mutex 的;中间释放锁,让其他线程可以工作。这样既避免了信号丢失,又保证了数据访问的安全性。

4.3 为什么用 while 而不是 if

再看一遍正确的写法:

pthread_mutex_lock(&mutex);
while (queue.empty()) {         // ← 用 while,不是 if
    pthread_cond_wait(&cond, &mutex);
}
// 取数据
pthread_mutex_unlock(&mutex);

为什么要用 while 循环?两个原因:

4.3.1 虚假唤醒(Spurious Wakeup)
定义:
线程在没有被 signal/broadcast 唤醒的情况下
从 wait 中返回

原因:
- Linux 内核的实现细节
- 信号中断等因素
- POSIX 标准允许虚假唤醒存在

例如:
系统收到某个信号(如 SIGCHLD)
可能导致 wait 提前返回

如果用 if:

if (queue.empty()) {
    pthread_cond_wait(&cond, &mutex);
}
// 虚假唤醒后,queue 可能还是空的
int data = queue.front();  // ← 错误!可能 crash

用 while:

while (queue.empty()) {
    pthread_cond_wait(&cond, &mutex);
}
// 只有 queue 非空才能退出循环
int data = queue.front();  // ← 安全
4.3.2 多消费者竞争
场景:
- 2个消费者线程 C1, C2
- 队列里只有1个数据
- 生产者调用 broadcast 唤醒所有等待者

过程:
1. C1 和 C2 都被唤醒
2. C1 先拿到锁,取走数据
3. C2 拿到锁时,队列已经空了

如果用 if:

// C2 拿到锁后
if (queue.empty()) {         // ← 已经检查过了,不会再检查
    pthread_cond_wait(&cond, &mutex);     // ← 不会执行
}
int data = queue.front();    // ← 错误!queue 是空的

用 while:

// C2 拿到锁后
while (queue.empty()) {      // ← 再次检查
    pthread_cond_wait(&cond, &mutex);     // ← 继续等待
}
int data = queue.front();    // ← 只有非空才执行

📌 推荐模式:while + wait(POSIX 允许虚假唤醒,多等待者也需要重新检查条件),因此实际工程里应默认使用 while。


五、signal vs broadcast

5.1 两者的区别

pthread_cond_signal(&cond):
- 唤醒至少一个等待线程(具体唤醒哪一个、是否公平由实现决定,不做保证)。


pthread_cond_broadcast(&cond):
- 唤醒所有等待线程
- 所有等待者都会被唤醒
- 它们会竞争锁,只有一个能先拿到

5.2 什么时候用 signal

场景:
- 只需要一个线程醒来处理
- 多个等待者的条件相同
- 例如:生产者放入一个数据,只需要一个消费者取

好处:
- 减少不必要的唤醒
- 降低锁竞争

示例:

// 生产者
pthread_mutex_lock(&mutex);
queue.push(data);
pthread_cond_signal(&cond);  // 只需要一个消费者醒来
pthread_mutex_unlock(&mutex);

5.3 什么时候用 broadcast

场景:
- 需要所有线程都检查条件
- 不同等待者的条件可能不同
- 例如:程序退出时,通知所有线程结束

好处:
- 确保所有相关线程都能响应

示例:

// 退出通知
pthread_mutex_lock(&mutex);
quit_flag = true;
pthread_cond_broadcast(&cond);  // 唤醒所有线程
pthread_mutex_unlock(&mutex);

// 各个线程
while (1) {
    pthread_mutex_lock(&mutex);
    while (!quit_flag && queue.empty()) {
        pthread_cond_wait(&cond, &mutex);
    }
    if (quit_flag) {
        pthread_mutex_unlock(&mutex);
        break;  // 退出循环
    }
    // 处理数据...
}

📌 选择建议:默认用 signal,除非明确需要唤醒所有线程时才用 broadcast。signal 更高效,broadcast 更保险。


六、条件变量的封装

6.1 Cond 类设计

继续在上一篇的 Lock.hpp 中添加条件变量的封装:

#pragma once
#include <pthread.h>

namespace LockModule
{
    // ... Mutex 和 LockGuard 的定义 ...
    
    class Cond
    {
    public:
        Cond(const Cond &) = delete;
        const Cond &operator=(const Cond &) = delete;
        
        Cond() {
            pthread_cond_init(&_cond, nullptr);
        }
        
        void Wait(Mutex &mutex) {
            pthread_cond_wait(&_cond, mutex.GetMutexOriginal());
        }
        
        void Signal() {
            pthread_cond_signal(&_cond);
        }
        
        void Broadcast() {
            pthread_cond_broadcast(&_cond);
        }
        
        ~Cond() {
            pthread_cond_destroy(&_cond);
        }
        
    private:
        pthread_cond_t _cond;
    };
}

6.2 使用示例

#include <iostream>
#include <queue>
#include <pthread.h>
#include <unistd.h>
#include "Lock.hpp"


using namespace LockModule;

std::queue<int> data_queue;
Mutex mutex;
Cond cond;
bool quit_flag = false;

void *consumer(void *arg) {
    int id = *(int *)arg;
    while (1) {
        mutex.Lock();
        while (data_queue.empty() && !quit_flag) {
            std::cout << "消费者" << id << ": 队列空,等待..." << std::endl;
            cond.Wait(mutex);  // 等待数据
        }
        
        if (quit_flag && data_queue.empty()) {
            mutex.Unlock();
            break;  // 退出
        }
        
        int data = data_queue.front();
        data_queue.pop();
        mutex.Unlock();
        
        std::cout << "消费者" << id << ": 消费数据 " << data << std::endl;
        usleep(100000);  // 模拟处理
    }
    std::cout << "消费者" << id << ": 退出" << std::endl;
    return nullptr;
}

void *producer(void *arg) {
    int id = *(int *)arg;
    for (int i = 0; i < 10; i++) {
        int data = id * 100 + i;
        
        mutex.Lock();
        data_queue.push(data);
        std::cout << "生产者" << id << ": 生产数据 " << data << std::endl;
        cond.Signal();  // 通知消费者
        mutex.Unlock();
        
        usleep(50000);
    }
    return nullptr;
}

int main() {
    pthread_t p1, p2, c1, c2;
    int pid1 = 1, pid2 = 2;
    int cid1 = 1, cid2 = 2;
    
    pthread_create(&c1, nullptr, consumer, &cid1);
    pthread_create(&c2, nullptr, consumer, &cid2);
    pthread_create(&p1, nullptr, producer, &pid1);
    pthread_create(&p2, nullptr, producer, &pid2);
    
    pthread_join(p1, nullptr);
    pthread_join(p2, nullptr);
    
    // 通知消费者退出
    mutex.Lock();
    quit_flag = true;
    cond.Broadcast();  // 唤醒所有消费者
    mutex.Unlock();
    
    pthread_join(c1, nullptr);
    pthread_join(c2, nullptr);
    
    return 0;
}

运行结果(部分):

消费者1: 队列空,等待...
消费者2: 队列空,等待...
生产者1: 生产数据 100
消费者1: 消费数据 100
生产者2: 生产数据 200
消费者2: 消费数据 200
生产者1: 生产数据 101
消费者1: 消费数据 101
...
消费者1: 退出
消费者2: 退出

七、条件变量的使用规范

7.1 标准模式

条件变量的使用遵循固定模式:

等待方:

pthread_mutex_lock(&mutex);
while (条件不满足) {
    pthread_cond_wait(&cond, &mutex);
}
// 条件满足,执行操作
pthread_mutex_unlock(&mutex);

通知方:

pthread_mutex_lock(&mutex);
// 修改条件
pthread_cond_signal(&cond);  // 或 broadcast
pthread_mutex_unlock(&mutex);

7.2 常见错误

7.2.1 忘记加锁
// 错误
while (queue.empty()) {
    pthread_cond_wait(&cond, &mutex);  // 调用前没有 lock
}

后果:未定义行为,可能导致死锁或 crash。

7.2.2 用 if 代替 while
// 错误
if (queue.empty()) {  // ← 应该用 while
    pthread_cond_wait(&cond, &mutex);
}

后果:虚假唤醒或多消费者竞争时,条件不满足也会继续执行。

7.2.3 signal 时不持有锁
// 有争议的写法
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond);  // unlock 后再 signal

pthread_cond_signal/broadcast 不一定要求调用时持有互斥锁,
但强烈建议: “持锁修改共享条件 →signal/broadcast → 解锁”作为一个整体完成。 这样能避免竞态窗口,并且更符合条件变量的典型用法。

如果 signal 在 unlock 后:
1. 线程A unlock
2. 等待线程B被唤醒
3. B尝试获取锁,但锁被别人又拿走了
4. B再次等待
5. 锁再次空闲
6. B再次被调度,拿到锁

如果 signal 在 unlock 前:
1. 线程A signal(B被唤醒但还拿不到锁)
2. A unlock(立即释放锁)
3. B拿到锁继续执行

第二种方式更高效,减少了调度

建议:在 unlock 前 signal

7.3 完整的使用清单

wait 前必须持有锁
✓ 用 while 循环检查条件,不用 if
✓ signal/broadcast 时持有锁
✓ 条件判断和数据访问都在锁的保护下
✓ 用 signal 还是 broadcast 要根据场景选择
✓ 记得初始化和销毁条件变量

📌 记住这个模板:把标准模式背下来,遇到同步问题直接套用,不容易出错。


八、条件变量的底层实现

8.1 Linux futex 机制

条件变量的底层依赖 futex (Fast Userspace Mutex)

futex 是什么:
- Linux 提供的快速用户空间同步机制
- 用户态 + 内核态混合实现
- pthread_mutex 和 pthread_cond 都基于 futex

两个系统调用:
int futex(int *uaddr, int op, int val, ...);

操作:
FUTEX_WAIT:原子地检查*uaddr,如果等于val则休眠
FUTEX_WAKE:唤醒等待在uaddr上的线程

8.2 pthread_cond_wait 的简化实现

以下为“思想级简化示意”,用于理解“等待队列 + futex 休眠/唤醒”的方向,不等价于 glibc 真实实现细节。

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex) {
    // 1. 加入等待队列
    add_to_wait_queue(cond);
    
    // 2. 释放 mutex(用户态操作)
    atomic_store(&mutex->lock, 0);
    if (mutex->waiters > 0) {
        futex_wake(&mutex->lock, 1);  // 唤醒等待 mutex 的线程
    }
    
    // 3. 等待条件变量(内核态休眠)
    futex_wait(&cond->futex, expected_value);
    
    // 4. 被唤醒,重新获取 mutex
    while (!atomic_compare_and_swap(&mutex->lock, 0, 1)) {
        futex_wait(&mutex->lock, 1);
    }
    mutex->owner = gettid();
    
    // 5. 从等待队列移除
    remove_from_wait_queue(cond);
    
    return 0;
}

8.3 pthread_cond_signal 的简化实现

int pthread_cond_signal(pthread_cond_t *cond) {
    // 检查是否有等待者
    if (cond->waiters == 0) {
        return 0;  // 没有等待者,直接返回
    }
    
    // 唤醒一个等待线程
    futex_wake(&cond->futex, 1);
    
    return 0;
}

📌 理解关键:在 Linux/glibc 上常见实现会借助 futex 完成阻塞/唤醒(实现细节依版本/库而异),它们在内核里操作等待队列,实现高效的休眠和唤醒。


九、本篇总结

9.1 核心知识点

1. 同步 vs 互斥

互斥:同一时刻只有一个线程访问(保护数据)
同步:线程之间协作配合(协调时序)
关系:同步建立在互斥之上

2. 条件变量的作用

问题:轮询浪费CPU,响应延迟,锁竞争
方案:条件变量让线程高效等待
     - 等待时不占CPU
     - 被唤醒立即响应
     - 配合 mutex 使用

3. pthread_cond API

函数 作用
pthread_cond_init 初始化条件变量
pthread_cond_wait 等待条件满足
pthread_cond_signal 唤醒一个线程
pthread_cond_broadcast 唤醒所有线程
pthread_cond_destroy 销毁条件变量

4. pthread_cond_wait的原理

前提:调用前持有 mutex
步骤:
  1. 原子地释放 mutex 并加入等待队列
  2. 线程休眠
  3. 被唤醒,尝试重新获取 mutex
  4. 获取成功,wait 返回(仍持有锁)

5. 使用规范

✓ 用 while 循环,不用 ifwait 前必须持有锁
✓ signal/broadcast 时持有锁
✓ 条件判断在锁保护下

9.2 最重要的理解

📌 条件变量的精髓

1. 条件变量 = 等待队列 + 通知机制
   - 不满足条件时,加入队列休眠
   - 条件满足时,唤醒等待者

2. 必须配合 mutex 使用
   - mutex 保护共享数据
   - cond 协调线程时序
   - wait 原子地释放锁并等待

3. 标准模式要记牢
   - while + wait(等待方)
   - 修改 + signal(通知方)
   - 永远不用 if

4. signal vs broadcast
   - signal:唤醒一个(常用)
   - broadcast:唤醒所有(退出通知等)

💬 下篇预告:条件变量的知识掌握后,我们就可以实现经典的生产者-消费者模型了。下篇会从模型原理讲起,实现两种版本:基于阻塞队列(BlockingQueue)的版本,以及基于环形队列(Ring Buffer)+ 信号量(Semaphore)的版本。通过这个经典模型,加深对同步互斥的理解,并学会用信号量解决实际问题。

👍 点赞、收藏与分享:如果这篇文章对你有帮助,请点赞、收藏并分享!

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐