【操作系统】12.Linux 多线程同步与互斥详解

目录

一、互斥(Mutex)

[1. 不互斥抢票导致数据错误](#1. 不互斥抢票导致数据错误)

[2. 为什么需要互斥?](#2. 为什么需要互斥?)

[3. 互斥锁](#3. 互斥锁)

[4. 进程间互斥](#4. 进程间互斥)

[5. 加锁后的线程切换](#5. 加锁后的线程切换)

[6. RAII 风格的锁(智能指针风格)](#6. RAII 风格的锁(智能指针风格))

二、锁的原理

[1. 硬件实现](#1. 硬件实现)

[2. 软件实现](#2. 软件实现)

三、线程同步

[1. 线程饥饿](#1. 线程饥饿)

[2. 同步要求](#2. 同步要求)

[3. 线程等待与唤醒(条件变量)](#3. 线程等待与唤醒(条件变量))

[4. 条件变量的用处](#4. 条件变量的用处)

四、生产者消费者模型

[1. 现实类比](#1. 现实类比)

[2. 321 原则](#2. 321 原则)

[3. 好处](#3. 好处)

五、阻塞队列模型

[1. 阻塞队列](#1. 阻塞队列)

[2. 成员声明](#2. 成员声明)

[3. 伪唤醒](#3. 伪唤醒)

[4. 生产者插入](#4. 生产者插入)

[5. 消费者执行](#5. 消费者执行)

[6. 唤醒与解锁的先后顺序](#6. 唤醒与解锁的先后顺序)

[7. 效率高的原因](#7. 效率高的原因)

[六、POSIX 信号量](#六、POSIX 信号量)

[1. 信号量回顾](#1. 信号量回顾)

[2. 多线程资源场景](#2. 多线程资源场景)

[3. 环形队列](#3. 环形队列)

[4. 数组模拟](#4. 数组模拟)

七、基于环形队列和信号量的生产消费模型

[1. 信号量封装](#1. 信号量封装)

[2. 生产者消费者操作](#2. 生产者消费者操作)

[3. 与二元信号量对照](#3. 与二元信号量对照)

八、日志库设计

[1. 输出策略接口(策略模式)](#1. 输出策略接口(策略模式))

[2. 日志器类(管理策略)](#2. 日志器类(管理策略))

[3. 日志信息类(logmsg)](#3. 日志信息类(logmsg))

[4. 使用流程](#4. 使用流程)

[5. 宏简化使用](#5. 宏简化使用)

九、线程池

[1. 成员声明与构造](#1. 成员声明与构造)

[2. 激活线程池](#2. 激活线程池)

[3. 任务处理函数](#3. 任务处理函数)

[4. 插入任务](#4. 插入任务)

[5. 停止线程池](#5. 停止线程池)

十、单例模式

[1. 实现方式](#1. 实现方式)

[2. 为线程池应用单例模式](#2. 为线程池应用单例模式)

十一、死锁

[1. 可重入与线程安全](#1. 可重入与线程安全)

[2. 死锁的四个必要条件(缺一不可)](#2. 死锁的四个必要条件(缺一不可))

[十二、C++ 标准库的线程安全](#十二、C++ 标准库的线程安全)


一、互斥(Mutex)

1. 不互斥抢票导致数据错误

  • 创建多个线程执行抢票逻辑:

    cpp 复制代码
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, NULL, route, (void*)"thread 1");
    pthread_create(&t2, NULL, route, (void*)"thread 2");
    pthread_create(&t3, NULL, route, (void*)"thread 3");
    pthread_create(&t4, NULL, route, (void*)"thread 4");
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
  • 结果 :票数被抢到负数(如 -2 张)。
    https://media/image1.png

  • 使用互斥锁后

    cpp 复制代码
    void* route(void* arg) {
        char* id = (char*)arg;
        while (1) {
            pthread_mutex_lock(&mutex);
            if (ticket > 0) {
                usleep(1000);
                printf("%s sells ticket:%d\n", id, ticket);
                ticket--;
                pthread_mutex_unlock(&mutex);
            } else {
                pthread_mutex_unlock(&mutex);
                break;
            }
        }
        return nullptr;
    }
  • 结果 :有序进行,数据正确。
    https://media/image2.png

2. 为什么需要互斥?

  1. ticket-- 操作非原子

    • -- 操作在汇编层面需要三步:从内存读到寄存器 -> 寄存器减1 -> 写回内存。

    • 当线程 A 执行完两步后被切出,线程 B 执行,最后写回内存的数字可能被覆盖,导致数据不一致。

  2. if (ticket > 0) 判断也是运算

    • 多个线程同时判断为真,都被切出,之后都执行 -- 操作,可能将票数减为负数。
  3. 线程切换

    • 线程被切走的原因:时间片用完、阻塞 I/O、sleep 等,会陷入内核。

    • 线程被切回:从内核态返回到用户态时会进行检查。

3. 互斥锁

  • 作用:保证同一时刻只有一个线程执行临界区代码。

  • 锁本身也是临界资源,因此锁的获取和释放操作必须是原子的。

  • 本质 :在执行临界区代码时,让线程执行由并行 改为串行

4. 进程间互斥

  • 在进程管道通信时,可以将管道头部强转为锁对应的类型,将管道变为锁,实现进程间互斥。

5. 加锁后的线程切换

  • 加锁后,线程允许切换和调度。

  • 但持有锁的线程被切出时,因为没有释放锁,其他线程即使获得 CPU 也无法进入临界区。

  • 因此,对于其他线程来说,持有锁的线程要么在运行,要么就绪,它独占临界区,这体现了原子性

6. RAII 风格的锁(智能指针风格)

cpp 复制代码
class guard_thread {
public:
    guard_thread(my_thread* th)
        :_th(th) {
        pthread_mutex_lock(_th->_m);
    }
    ~guard_thread() {
        pthread_mutex_unlock(_th->_m);
    }
private:
    my_thread* _th;
};
  • 在循环中创建对象时自动加锁,对象销毁时自动解锁,防止忘记解锁。

二、锁的原理

1. 硬件实现

  • 可以通过关闭 CPU 中断(时钟中断)来实现。但这样做有风险,只能在操作系统内部使用。

2. 软件实现

  • 锁申请流程 :初始化(锁值为1,线程值为0)-> 交换(exchange)-> 验证。

  • 因为 1 只有一个,验证时值为 1 的线程就抢到了锁,可以运行程序;其他线程挂起。

  • 这个流程在任意异步时刻被打断也没关系,因为 1 的个数没变。

  • 交换的本质:获取锁时,交换操作就是将线程的数据(0)交换到 CPU 的寄存器,同时将锁的值(1)取出来。

三、线程同步

1. 线程饥饿

  • 高频申请锁的线程可能导致其他线程长期申请不到锁,产生饥饿。

  • 因为刚运行结束的线程不需要被唤醒(它还在运行),而没运行的线程需要被唤醒。运行结束的线程在竞争锁时有优势,可能一直由它运行。

2. 同步要求

  • 线程不能立即申请第二次锁,申请时要按特定次序进行。

  • 这样可以让锁的分配更加公平。

3. 线程等待与唤醒(条件变量)

  • 使用 cond(条件变量)相关接口。

  • pthread_cond_init / PTHREAD_COND_INITIALIZER:初始化。

  • pthread_cond_wait(&gcond, &glock):在 glock 锁定的情况下,阻塞等待 gcond 信号,并自动释放锁

  • pthread_cond_signal(&gcond):唤醒一个因 gcond 阻塞的线程。

  • pthread_cond_broadcast(&gcond):唤醒所有因 gcond 阻塞的线程。

4. 条件变量的用处

  • 当条件不满足时,线程会等待并休眠,直到被唤醒才继续执行。

  • 判断条件是否满足需要使用临界区资源,说明线程就在临界区内。因此,让线程休眠时必须让它释放锁,以便其他线程可以进入临界区修改条件。

  • pthread_cond_wait 的设计就是为了实现这一点:它原子性地释放锁并进入休眠 ,被唤醒后重新获取锁

四、生产者消费者模型

1. 现实类比

  • 货物经历:工厂 -> 超市 -> 消费者。

  • 对应数据:线程 -> 内存相关区域(交易场所) -> 线程。

  • 可用于多线程通信。

2. 321 原则

  • 3种关系

    • 消费者之间:互斥关系。

    • 生产者之间:互斥关系(竞争资源)。

    • 消费者与生产者之间:互斥与同步关系(缓冲区满时生产者等,空时消费者等)。

  • 2个角色:消费者、生产者。

  • 1个交易场所:超市 -> 内存空间(如队列)。

3. 好处

  • 解耦合:生产者和消费者互相不直接影响。

  • 支持忙闲不均:生产者和消费者的速度可以不同。

  • 提高效率:并发执行。

五、阻塞队列模型

1. 阻塞队列

  • 队列有内容:消费者才能读取,否则阻塞。

  • 队列不满:生产者才能写入,否则阻塞。

2. 成员声明

cpp 复制代码
std::queue<T> _qu;
int _cap;
int _csleep;  // 等待的消费者数量
int _psleep;  // 等待的生产者数量
pthread_mutex_t _mutex;
pthread_cond_t _full_cond;   // 队列满时生产者等待
pthread_cond_t _empty_cond;  // 队列空时消费者等待
  • 生产者和消费者竞争同一把锁。

  • _full_cond:控制生产者,队列不满时才可生产。

  • _empty_cond:控制消费者,队列不空时才可消费。

  • queue 是临界资源,需要加锁保护。

3. 伪唤醒

  • 当异常(如被信号中断)导致多唤醒了几个生产者后,这些生产者从 wait 返回,但此时队列可能又满了。

  • pthread_cond_wait 在被唤醒后,还需要重新获取锁才能继续执行。

  • 因此,线程继续执行需要两个条件:收到唤醒信号 + 拿到锁。

  • 解决方式 :将判断条件的 if 改为 while,被唤醒后再次检查条件。

4. 生产者插入

cpp 复制代码
void enque(const T val) {
    pthread_mutex_lock(&_mutex);
    while (isfull()) {
        _psleep++;
        std::cout << "生产者,进入休眠了: " << _psleep << std::endl;
        pthread_cond_wait(&_full_cond, &_mutex);
        _psleep--;
    }
    _qu.push(val);
    if (_csleep) {
        pthread_cond_signal(&_empty_cond);
        std::cout << "唤醒消费者" << std::endl;
    }
    pthread_mutex_unlock(&_mutex);
}
  • 当多个生产者被唤醒,其中一个插入 val 后队列又满了。此时 while 循环再次触发,发现满了,其他被唤醒的生产者会再次阻塞。

5. 消费者执行

cpp 复制代码
T pop() {
    pthread_mutex_lock(&_mutex);
    while (isempty()) {
        _csleep++;
        pthread_cond_wait(&_empty_cond, &_mutex);
        _csleep--;
    }
    T ret = _qu.front();
    _qu.pop();
    if (_psleep) {
        pthread_cond_signal(&_full_cond);
        std::cout << "唤醒生产者" << std::endl;
    }
    pthread_mutex_unlock(&_mutex);
    return ret;
}

6. 唤醒与解锁的先后顺序

  • 先后关系都可以。

  • 但先唤醒后解锁,意味着被唤醒的线程一定拿不到锁(因为锁还在当前线程手中),只能再次阻塞等待锁。

7. 效率高的原因

  • 生产者和消费者在访问临界区时虽然是串行的,但在现实中,任务可能来自另一个模块,会有等待。

  • 这个模型允许生产和消费并发执行:当生产者等待时(队列满),消费者可以执行任务;当消费者等待时(队列空),生产者可以生产。

六、POSIX 信号量

1. 信号量回顾

  • 类比电影票,是一种资源的计数器预定机制

  • 之前的互斥锁可以看作二元信号量(计数为 1),同一时刻只有一个线程可进入。

2. 多线程资源场景

  • 目标资源整体使用:使用互斥锁。

  • 目标资源分块使用:使用信号量。

3. 环形队列

  • 头指针 (Head):消费者指针。

  • 尾指针 (Tail):生产者指针。

  • 指针同位置:队列可能为空,也可能为满。

    • 满:需要消费者处理。

    • 空:需要生产者处理。

  • 指针不同位置:生产者和消费者可以同时处理。

4. 数组模拟

  • 指针位置通过对数组容量取模(% N)实现。

七、基于环形队列和信号量的生产消费模型

1. 信号量封装

cpp 复制代码
class sem {
public:
    sem(int sem_value = 1) {
        sem_init(&_sem, 0, sem_value);
    }
    void P() { // 申请资源(wait)
        sem_wait(&_sem);
    }
    void V() { // 释放资源(post)
        sem_post(&_sem);
    }
    ~sem() {
        sem_destroy(&_sem);
    }
private:
    sem_t _sem;
};
  • P 操作:等待资源,资源数减1(原子操作)。

  • V 操作:释放资源,资源数加1(原子操作)。

  • 信号量将临界资源是否可用、就绪等操作变为原子的。

2. 生产者消费者操作

cpp 复制代码
void enqueue(const T& val) {
    _blank_sem.P();  // 申请空格子资源
    LockGuard lg(_c_mutex); // 保护临界区(环形队列)
    _rq[_p_step] = val;
    _p_step = (_p_step + 1) % _cap;
    _full_sem.V();  // 增加数据资源
}
void pop(T* val) {
    _full_sem.P();  // 申请数据资源
    LockGuard lg(_p_mutex);
    *val = _rq[_c_step];
    _c_step = (_c_step + 1) % _cap;
    _blank_sem.V(); // 增加空格子资源
}
  • 申请信号量和加锁的先后顺序

    • 先申请信号量再申请锁:效率更高。类比买票,先买到票再排队;如果先排队再买票,可能排到了却买不到票(白排了)。

3. 与二元信号量对照

  • 资源可以拆分(如多个缓冲区单元):使用计数信号量。

  • 资源不可拆分(如一个变量):使用互斥锁(二元信号量)。

八、日志库设计

1. 输出策略接口(策略模式)

cpp 复制代码
class logmodule {
public:
    virtual void synclog(const std::string &msg) = 0;
    virtual ~logmodule() = default;
};
  • 控制台输出策略

    cpp 复制代码
    class consolelogstrategy : public logmodule {
    public:
        consolelogstrategy() {}
        void synclog(const std::string &msg) {
            LockGuard lg(_mu);
            std::cout << msg << "\r\n";
        }
        ~consolelogstrategy() {}
    private:
        Mutex _mu;
    };
  • 文件输出策略

    cpp 复制代码
    class filelogstrategy : public logmodule {
    public:
        filelogstrategy(const std::string &path = "./log", const std::string &file = "log.log")
            : _path(path), _file(file) {
            LockGuard lg(_mu);
            if (std::filesystem::exists(path)) return;
            std::filesystem::create_directories(path);
        }
        ~filelogstrategy() {}
        void synclog(const std::string &msg) {
            LockGuard lg(_mu);
            std::string &&filename = _path + (_path[_path.size() - 1] == '/' ? "" : "/") + _file;
            std::ofstream out(filename, std::ios::app);
            out << msg << "\r\n";
            out.close();
        }
    private:
        Mutex _mu;
        std::string _path;
        std::string _file;
    };

2. 日志器类(管理策略)

cpp 复制代码
class log {
public:
    log() {
        enableconsolelogstrateg(); // 默认控制台输出
    }
    void enableconsolelogstrateg() {
        _strategy = std::make_unique<consolelogstrategy>();
    }
    void enablefilelogstrategy() {
        _strategy = std::make_unique<filelogstrategy>();
    }
    class logmsg; // 前向声明
    logmsg operator()(LogLevel level, std::string file_name, int line) {
        return logmsg(level, file_name, line, *this);
    }
private:
    std::unique_ptr<logmodule> _strategy;
};
  • 使用智能指针和基类,可以在运行时切换输出策略。

3. 日志信息类(logmsg

  • 目标格式:[2026-02-22 16:14:42] [DEBUG] [2955193] [main.cpp] [5] - This is a debug message.
cpp 复制代码
class logmsg {
public:
    std::string LevelStr(LogLevel level) {
        switch (level) {
            case LogLevel::DEBUG: return "DEBUG";
            case LogLevel::INFO: return "INFO";
            case LogLevel::WARNING: return "WARNING";
            case LogLevel::ERROR: return "ERROR";
            case LogLevel::FATAL: return "FATAL";
            default: return "UNKNOWN";
        }
    }
    logmsg(LogLevel& level, std::string &file_name, int line, log& logger)
        : _level(level), _file_name(file_name), _line(line), _logger(logger) {
        _pid = getpid();
        time_t now = time(nullptr);
        struct tm curr_time;
        localtime_r(&now, &curr_time);
        char time_buffer[128];
        snprintf(time_buffer, sizeof(time_buffer), "%4d-%02d-%02d %02d:%02d:%02d",
                 curr_time.tm_year + 1900, curr_time.tm_mon + 1, curr_time.tm_mday,
                 curr_time.tm_hour, curr_time.tm_min, curr_time.tm_sec);
        _curr_time = time_buffer;
        std::stringstream ss;
        ss << "[" << _curr_time << "] "
           << "[" << LevelStr(_level) << "] "
           << "[" << _pid << "] "
           << "[" << _file_name << "] "
           << "[" << _line << "] "
           << "- ";
        _loginfo = ss.str();
    }
    template <class T>
    logmsg &operator<<(const T &data) {
        std::stringstream ss;
        ss << data;
        _loginfo += ss.str();
        return *this;
    }
    ~logmsg() {
        _logger._strategy->synclog(_loginfo);
    }
private:
    std::string _curr_time;
    LogLevel _level;
    pid_t _pid;
    std::string _file_name;
    int _line;
    std::string _loginfo; // 拼接好的日志信息
    log &_logger;
};
  • 关键 :析构函数中调用 synclog 输出,实现了自动打印。

4. 使用流程

  1. 定义全局或局部 log 对象(程序结束时析构)。

  2. log 对象默认选择控制台输出模式(可手动切换)。

  3. 打印日志:

    复制代码
    logger(LogLevel::DEBUG, __FILE__, __LINE__) << "This is a debug message.";
  4. 执行过程:

    • 调用 operator(),返回一个临时的 logmsg 对象(调用其构造函数)。

    • 调用 operator<< 将消息内容追加到 _loginfo

    • 临时对象生命周期结束,调用析构函数,将完整的日志信息通过策略输出。

5. 宏简化使用

cpp 复制代码
#define LOG(level) logger(level, __FILE__, __LINE__)
// 使用
LOG(LogLevel::DEBUG) << "This is a debug message.";

九、线程池

1. 成员声明与构造

cpp 复制代码
std::vector<mythread> _threads;
int _num;
std::queue<T> _tasks;
Mutex _mu;
Cond _co;
bool _isrunning;
int _sleepercnt; // 休眠线程数量
  • _threads:存放线程对象。

  • _tasks:存放任务(可调用对象包装器)。

  • _sleepercnt_isrunning:用于控制线程池状态。

cpp 复制代码
threadpool(int num = 5)
    : _num(num), _sleepercnt(0), _isrunning(0) {
    for (int i = 0; i < num; i++) {
        _threads.emplace_back([this]() { HandlerTask(); });
    }
}

2. 激活线程池

cpp 复制代码
void start() {
    _isrunning = 1;
    for (int i = 0; i < _num; i++) {
        _threads[i].start();
    }
}

3. 任务处理函数

cpp 复制代码
void HandlerTask() {
    char name[128];
    pthread_getname_np(pthread_self(), name, strlen(name));
    while (1) {
        T t;
        {
            LockGuard lg(_mu);
            // 线程休眠条件:没有任务 且 线程池在运行
            while (_tasks.empty() && _isrunning == 1) {
                _sleepercnt++;
                std::cout << _sleepercnt << std::endl;
                _co.Wait(_mu);
                _sleepercnt--;
            }
            // 线程退出条件:线程池停止 且 没有任务
            if (_isrunning == 0 && _tasks.empty()) {
                LOG(LogLevel::DEBUG) << name << "退出";
                break;
            }
            t = _tasks.front();
            _tasks.pop();
        }
        t(); // 执行任务
    }
}
  • 唤醒条件

    1. 有任务到来。

    2. 线程池被销毁(广播唤醒)。

  • 被唤醒后,如果 _isrunning == 1,说明有任务要执行;否则说明线程池要销毁,线程退出。

4. 插入任务

cpp 复制代码
bool enque(const T &val) {
    if (_isrunning == 1) {
        LockGuard lg(_mu);
        _tasks.push(val);
        if (_threads.size() == _sleepercnt) { // 所有线程都在休眠
            _co.Signal();
            LOG(LogLevel::INFO) << "唤醒一个线程";
            return 1;
        }
    }
    return 0;
}
  • 当插入任务且所有线程都在休眠时,唤醒一个线程来处理。

5. 停止线程池

cpp 复制代码
void stop() {
    _isrunning = 0;
    _co.Broadcast(); // 唤醒所有等待的线程
    LOG(LogLevel::INFO) << "唤醒所有线程";
}
  • 先设置停止标志,然后广播唤醒所有线程,让它们根据 HandlerTask 中的逻辑退出。

十、单例模式

1. 实现方式

  • 饿汉模式

    cpp 复制代码
    template <typename T>
    class Singleton {
        static T data;
    public:
        static T* GetInstance() {
            return &data;
        }
    };
    • 静态 T 对象,在程序一开始就创建。

    • 缺点:可能延长程序启动时间(如果不使用)。

  • 懒汉模式

    cpp 复制代码
    template <typename T>
    class Singleton {
        static T* inst;
    public:
        static T* GetInstance() {
            if (inst == NULL) {
                inst = new T();
            }
            return inst;
        }
    };
    • 只有首次调用 GetInstance 时才创建对象。

    • 页表的原理和它类似。

2. 为线程池应用单例模式

  1. 禁用拷贝构造和赋值重载

    cpp 复制代码
    threadpool(const threadpool<T> &tp) = delete;
    threadpool<T> &operator=(const threadpool<T> &tp) = delete;
  2. 静态指针和静态获取方法

    cpp 复制代码
    static threadpool<T> *_tp; // 在类外初始化为 nullptr
    static threadpool<T> *getptr() {
        if (_tp == nullptr) {
            LockGuard lg(_mutex);
            if (_tp == nullptr) {
                LOG(LogLevel::INFO) << "获取单例";
                _tp = new threadpool<T>();
                _tp->start();
            }
        }
        return _tp;
    }
    • 双检锁保证线程安全。

十一、死锁

1. 可重入与线程安全

  • 可重入:多个执行流同时执行不出问题(同一个函数被多次调用,结果正确)。

  • 线程安全:多个线程访问共享资源时能正确执行。

  • 关系

    • 可重入一定线程安全。

    • 线程安全不一定可重入。

  • 反例

    • 一个线程拿到锁 -> 收到信号(如 Ctrl+C 触发的信号处理函数)-> 信号处理函数调用需要同一把锁的函数。

    • 该线程持有锁,信号处理函数在同一线程中执行,再次申请锁,导致自己锁住自己(死锁)。

    • 但多线程中这个反例不太容易触发,因为执行信号处理函数的是接收到信号的线程(主线程),不是其他线程。

  • 在一般情况下,可以将可重入和线程安全看作一个硬币的两面。

2. 死锁的四个必要条件(缺一不可)

  1. 互斥:资源一次只能被一个执行流使用。

  2. 不剥夺:执行流不能抢夺其他执行流已持有的资源。

  3. 请求与保持:执行流在等待其他资源时,不释放自己已持有的资源。

  4. 循环等待:存在一个执行流等待链,如 A 等 B,B 等 A。

  • 类比:两个小孩买糖。

    • 互斥:两人不能吃同一块糖。

    • 不剥夺:不能抢对方的钱。

    • 请求与保持:两人都手拿钱,等着对方的糖。

    • 循环等待:两人都不让。

十二、C++ 标准库的线程安全

  1. STL(标准模板库)

    • 大多数容器不是线程安全的。因为加锁会影响性能,需要用户自行加锁。
  2. 智能指针

    • 一般使用不涉及线程安全问题,因为通常只在当前代码作用域内使用。

    • 但库设计时考虑到了线程安全,例如 shared_ptr 的引用计数操作是原子的。

相关推荐
小李独爱秋1 小时前
模拟面试:简述一下MySQL数据库的备份方式。
数据库·mysql·面试·职场和发展·数据备份
難釋懷2 小时前
Redis消息队列-基于Stream的消息队列-消费者组
数据库·redis·缓存
四七伵2 小时前
数据库必修课:MySQL金额字段用decimal还是bigint?
数据库·后端
diaya3 小时前
麒麟V10 x86系统安装mysql
数据库·mysql
LaughingZhu3 小时前
Product Hunt 每日热榜 | 2026-02-24
大数据·数据库·人工智能·经验分享·搜索引擎
QEasyCloud20224 小时前
WooCommerce 独立站系统集成技术方案
java·前端·数据库
数据知道4 小时前
MongoDB 数组查询专项:`$all`、`$elemMatch` 与精确匹配数组的使用场景
数据库·mongodb
柒.梧.4 小时前
Java位运算详解:原理、用法及实战场景(面试重点)
开发语言·数据库·python
callJJ4 小时前
深入浅出 MVCC —— 从零理解 MySQL 并发控制
数据库·mysql·面试·并发·mvcc