atomic凭什么线程安全?无锁编程为何有时比锁还慢?C++高并发必懂的原子操作真相!

@toc

本篇摘要

本文详解C++ atomic 类型,涵盖其线程安全原理、CAS操作(含weak/strong区别)、内存顺序选项及无锁编程实践。通过对比无锁栈与有锁栈性能,阐明低并发用锁、高并发用无锁的选型逻辑,并附自旋锁实现,帮助理解原子操作核心机制。

一.atomic介绍

简单介绍

  1. atomic的定义 :atomic是一个模板的实例化和全特化均定义的原子类型,它能够保证对一个原子对象的操作是线程安全的(因为如果临界区太小,频繁加解锁会在等待和唤醒的时候付出代价,因此引入了atomic这个原子类型 )。

  2. 高版本的语法支持智能指针管理atomic,对应atomic的话,它是支持全部内置类型,但是自定义类型就不一定了。

  3. 对应atomic<int> 这个类型其实就是atomic_int(这个是atomic<int> typedef出来的)

  4. atomic对T类型的要求 :atomic可以使用任何满足可复制构造(CopyConstructible)和可复制赋值(CopyAssignable)的可平凡复制(TriviallyCopyable)类型T进行实例化。当使用以下几个函数对T类型进行判断时,如果其中任何一个函数返回false,则用于atomic的操作就不是原子操作。

cpp 复制代码
std::is_trivially_copyable<T>::value
std::is_copy_constructible<T>::value
std::is_move_constructible<T>::value
std::is_copy_assignable<T>::value
std::is_move_assignable<T>::value
std::is_same<T, typename std::remove_cv<T>::type>::value
  • 因此我们使用自定义类型的时候就可以检测下,如果不符合,那么就不能使用atomic< type >了(比如string就不是原子的)。

比如进行atomic定义整型 指针变量的时候,这个atomic类特化了一些函数,如下:

函数 描述 备注
为整数、浮点数(C++20 起)和指针类型特化
fetch_add 原子地将实参加到存储于原子对象的值上,并返回先前保有的值 (公开成员函数)
fetch_sub 原子地从存储于原子对象的值减去实参,并获得先前保有的值 (公开成员函数)
operator+= operator-= 与原子值进行加、减 (公开成员函数)
仅为整数和指针类型特化
fetch_max (C++26) 原子地进行实参和原子对象的值的 std::max,并获得先前保有的值 (公开成员函数)
fetch_min (C++26) 原子地进行实参和原子对象的值的 std::min,并获得先前保有的值 (公开成员函数)
operator++ operator++(int) operator-- operator--(int) 令原子值增加或减少一 (公开成员函数)
仅为整数类型特化
fetch_and 原子地进行实参和原子对象的值的逐位与,并获得先前保有的值 (公开成员函数)
fetch_or 原子地进行实参和原子对象的值的逐位或,并获得先前保有的值 (公开成员函数)
fetch_xor 原子地进行实参和原子对象的值的逐位异或,并获得先前保有的值 (公开成员函数)
operator&= operator = operator^= 与原子值进行逐位与、或、异或 (公开成员函数)

==举个例子:==

比如之前讲的单核 多核cpu操作的时候,就拿简单的加法计算操作来说,由于对应加法操作不是原子的,也就不是一条指令而是多条指令(看汇编可以看出),因此会造成多线程情况下造成意外发生(之前在上几篇,线程不安全的介绍中讲过,这里就不多说了)。

而对应atomic,它就保证读取与写入是同一个指令操作;这样就不会造成多线程操作导致的不安全事件的发生。

atomic原理分析

  1. load 和 store 可原子读写 atomic 封装的 T 对象。
  2. atomic 原理依赖硬件:现代处理器提供原子指令(如 x86 的 CMPXCHG),能执行不可分割的读 - 比较 - 写操作(CAS 等);还采用缓存一致性协议,保证多处理器缓存中变量副本的一致性(也就是多核的时候,拿到内存中的变量应该保持正确性,比如要么旧数据标记无效,要么及时同步最新被修改的数据)。

==对应CAS操作的接口,不同平台都有封装,C++11也提供了:==

GCC支持的CAS接口:

cpp 复制代码
bool __sync_bool_compare_and_swap (type *ptr, type oldval, type newval);
type __sync_val_compare_and_swap (type *ptr, type oldval, type newval);

Windows支持的CAS接口:

cpp 复制代码
InterlockedCompareExchange (
    __inout LONG volatile *Target,
    __in LONG Exchange,
    __in LONG Comperand
);

C++11支持的CAS接口:

cpp 复制代码
template <class T>
bool atomic_compare_exchange_weak (atomic<T>* obj, T* expected, T val) noexcept;

template <class T>
bool atomic_compare_exchange_strong (atomic<T>* obj, T* expected, T val) noexcept;

C++11中atomic类的成员函数:

cpp 复制代码
bool compare_exchange_weak (T& expected, T val, 
                            memory_order sync = memory_order_seq_cst) noexcept;

bool compare_exchange_strong (T& expected, T val, 
                              memory_order sync = memory_order_seq_cst) noexcept;

先看图抽象理解下CAS的操作:

下面通过atomic内部调用的函数来模拟下atomic的整型变量++的操作:

首先先看段模拟的关于普通cnt++与atomic修饰后++的程序:

cpp 复制代码
#include<atomic>
#include <iostream>
#include <thread>
#include <mutex>
#include<vector>
//atomic<int> acnt;
atomic_int acnt; //typedef的
int cnt = 0;
void Add1(atomic<int>& cnt)
{
    int old = cnt.load();
 
    while (!atomic_compare_exchange_weak(&cnt, &old, old + 1));//系统接口
   // while (!cnt.compare_exchange_weak(old, old + 1));//atomic封装的,里面能直接拿到对应的cnt值;两个等效
}

void f()
{
    for (int n = 0; n < 100000; ++n)
    {
        //++acnt;
        // Add1的用CAS模拟atomic的operator++的原子操作
        Add1(acnt);

        ++cnt;
    }
}
int main()
{
    std::vector<thread> pool;
    for (int n = 0; n < 4; ++n)
        pool.emplace_back(f);

    for (auto& e : pool)
        e.join();

    cout << "原子计数器为 " << acnt << '\n'
        << "非原子计数器为 " << cnt << '\n';

    return 0;
}

下面解释下:

  • 这里如果不是多线程的话,也就是单线程,此时走Add1函数,假设一开始acnt是0,此时&cnt拿到的是内存中也是0,发现和old(也就是在&cnt之前,从内存加载到寄存器中的值相等,此时就把old+1,也就是寄存器拿到的为1;之后再写会内存,此时acnt就变成1了)(对应acew函数返回true)。
  • 而这里atomic的出现是保证多线程的时候仍是安全的,也就是保证原子性,假设多线程出现这个情况:线程1进行acnt load后拿到1(也就是此刻内存中的值),但是在进行atomic_compare_exchange_weak的时候,瞬间一个线程刚完成对应的内存值修改,acnt变成2了,然后这个函数就会发现old为1,acnt为2;此时把old变成acnt;old再++变成3,进而内存中的acnt变成3了(此时acew函数返回false)。

下面看下对应对比效果:

  • 这里发现无论试多少次atomic操作的值(也就是原子性即线程安全的操作,无需加锁来付出代价)都是正确的,而cnt也就是线程不安全,连锁都没有故不确定性。

下面再举一个多线程进行链表头插的例子(这里通过atomic实现原子性而不是加锁):

对应代码:

cpp 复制代码
#include <atomic>         // std::atomic
#include <thread>         // std::thread
#include <vector>         // std::vector
//
// a simple global linked list:
struct Node { int value; Node* next; };
std::atomic<Node*> list_head(nullptr);

void append(int val) {     // append an element to the list
	std::this_thread::sleep_for(std::chrono::seconds(1));//多线程卡在在休眠,创建的差距可以忽略不计也就是不能保证谁先链接

	Node* oldHead = list_head.load();
	Node* newNode = new Node{ val,oldHead };

	//oldHead = newNode;//如果这里直接头插这样,此时无锁多线程就是不安全的
	// what follows is equivalent to: list_head = newNode, but in a thread-safe way:
	while (!list_head.compare_exchange_weak(oldHead, newNode))
		newNode->next = oldHead;
}

int main()
{
	// spawn 10 threads to fill the linked list:
	std::vector<std::thread> threads;
	for (int i = 0; i < 10; ++i) 
		threads.push_back(std::thread(append, i));

	for (auto& th : threads)
		th.join();

	// print contents:
	for (Node* it = list_head; it != nullptr; it = it->next)
		std::cout << ' ' << it->value;
	std::cout << '\n';

	// cleanup:
	Node* it; while (it = list_head) { list_head = it->next; delete it; }

	return 0;
}

如果这里直接就是oldHead = newNode;此时进行头插,多线程的时候可能会出现(某线程被插入忽略的情况):

而下面使用atomic类内的操作进行CAS操作保证线程安全,有两种情况,一种就是线程没有干扰,一种就是存在干扰:

第一种:

正常插入也就是compare_exchange_weak返回true,就是某线程插入的时候原头结点没有被改变,因此成功进行头插。

第二种:

下面看下运行结果(这里为了更方便看出,不知道哪个线程先进行的插入,因此通过sleep_for保证尽量同步):

  • 数字不少,故没有线程发生插入时候失败,而结果不一说明多线程操作线程有快有慢。

==因此总结下:==

  • 对于简单的操作(也就是临界区小的比如++,比较等),此时可以使用atomic自带的重载(包含CAS检测)来完成多线程安全,就不要加锁了;但是比如atomic检测后发现不是原子性的(也就是比如string),此时atomic<某某>就没用,只能老是调用系统等提供的CAS判断操作手动完成线程安全;或者就老实加锁。

  • 而上面关于链表指针的操作其实如果都换成atomic类型,此时无需CAS操作就是原子性的(atomic内部自己调用),但是atomic检测对应类型(那几个函数)不符合那么此时就不能这样。

  • 因此符合atomic类型的可以直接用,如果不符合就需要手动CAS或者加锁。

atomic 里面的compare_exchange_weak / strong两者区别

这两个是 C++ 原子操作(<atomic> 头文件) 提供的"比较并交换"(Compare-And-Swap,CAS)接口,属于硬件级别的原子操作,主要用来做多线程下的"无锁同步":

  1. compare_exchange_weak(expected, desired):

它会先比较原子变量的当前值是否等于 expected;如果相等,就把原子变量改成 desired,然后返回 true;否则就把 expected 改成原子变量的当前值,然后返回 false。所谓"weak(弱)",指的是它有可能在"值确实等于 expected"的情况下,仍然返回 false(也就是所谓的"虚假失败")。这种虚假失败往往是因为 CPU 缓存一致性、编译器优化等原因,导致硬件层面并没有真正去对比 / 交换,但还是返回了失败。

  1. compare_exchange_strong(expected, desired):

行为和 weak 几乎一样,区别在于它会 保证"只要原子变量的值真的等于 expected,就一定返回 true"(不会虚假失败)。为了做到这一点,它在硬件上可能需要做更多工作(比如严格的内存屏障、等待缓存同步完成,比如采用缓存一致协议等),所以在一些平台上可能比 weak 稍慢一点。

下面举个例子帮助理解下:

  • 线程1 和 线程2 同时读取 cnt,都得到 old = 0​​(初始值)。

  • ​​线程1 先执行 CAS,成功把 cnt从 0改成 1​​。

  • ​​线程2 执行 CAS 时,虽然 old = 0(它读取的值),但此时 cnt已经被线程1 改成 1,所以 CAS 失败​​(这是正常的竞争,不是虚假失败)。

  • ​​但如果线程1 和 线程2 的 CAS 操作在 CPU 缓存同步时出现延迟,线程2 可能仍然认为 cnt = 0(比如此时线程1改变cnt为1的值还没有写入内存或者还在准备些,而线程2就把原先的0读走了),但硬件 CAS 操作失败(虚假失败)(比如还可能有编译器优化导致读取cnt的时候读取的缓存还是0,这也是所谓的虚假失败)​​。

  • 因此strong对应的就是解决这种问题,因此判断比较准确些,但也消耗性能,因此 compare_exchange_weak还是我们常用的。

memory_order系列选项介绍

在C++11标准库中,std::atomic 提供了多种内存顺序 (memory_order) 选项,用于控制原子操作的内存同步行为(比如只保证了原子性,但是同步没保证也会造成一些问题)。这些选项允许开发者在性能和正确性之间进行权衡,特别是在多线程编程中。std::atomic 支持六种内存顺序选项。

下面认识下:

load和store可以原⼦的读取和修改atomic封装存储的T对象;其实就是管理对应atomic里面封装的不同类型的变量。

下面举个例子:

cpp 复制代码
std::atomic<bool> flag(false);
int data = 0;
// 线程 1
data = 42;
flag.store(true);
// 线程 2
while (!flag.load()) {}
std::cout << data; // 不一定 data = 42
  • 比如这里,虽然线程1改了对应data,可能还没同步给线程2,也就是线程2还没收到,此时修改了true,线程2退出循环,后发现data还是0,此时虽然保证原子性(但是因为atomic在c++11之后默认就是memory_order_seq_cst;因此也能保证内存同步。)。

此时就需要添加选项来完成手动对于内存的同步。

六种内存顺序选项:

  • memory_order_relaxed(宽松顺序)

    只保证"原子性"(比如一个操作要么全做完、要么全不做),但不保证不同线程之间的操作顺序,也不保证一个线程对数据的修改能立刻被其他线程看到。 适合那些"只需要原子操作、不关心顺序"的场景(比如统计一个全局计数器)。

  • memory_order_consume(依赖顺序)

    主要用于"加载操作",保证"依赖这条数据的后续操作"能看到这条数据的最新值。不过实际开发中用的不多,因为容易出错,也很少有场景必须用它。

  • memory_order_acquire(获取顺序)

    也是"加载操作"用的。它保证:在这条加载操作之前 发生的所有读/写操作,在其他线程里不能 被重排到这条加载操作之后 。 简单说就是:我读了某个值之后,之前所有对内存的操作结果,其他线程都得"认可",不能乱序把它们放到我读的后面。

  • memory_order_release(释放顺序)

    用于"存储操作"。它保证:在这条存储操作之后 发生的所有读/写操作,在其他线程里不能 被重排到这条存储操作之前 。 对应上面acquire的理解:如果一个线程做了release,另一个线程做了acquire并读到了这个值,那么release之后的所有写操作,在另一个线程看来都像是已经完成了(和memory_order_acquire进行连用)。

  • memory_order_acq_rel(获取-释放顺序)

    用于"读-修改-写"这类操作(比如fetch_add)。它同时有acquire和release的语义:既保证"修改前"的操作不会被重排到修改之后,也保证"修改后"的操作不会被重排到修改之前。 相当于对"读-改-写"整个过程加了"获取"和"释放"的约束。

  • memory_order_seq_cst(顺序一致性顺序,c++11后默认的选项)

    最严格的一种,对所有操作都生效。它保证了"全局顺序一致性"------所有线程看到的操作执行顺序都是一样的,就像有一个"全局时钟"在调度所有线程的操作一样。 虽然性能相对差一点,但能最大程度保证多线程下的正确性,如果你不确定该选哪个,用这个往往不会错。

表格说明:

内存顺序 适用操作 语义描述
memory_order_relaxed 任意操作 仅保证原子性,无同步或顺序约束。
memory_order_consume 加载操作 保证依赖链的可见性,实际使用较少。
memory_order_acquire 加载操作 保证当前操作之前的读写不会被重排序到之后。
memory_order_release 存储操作 保证当前操作之后的读写不会被重排序到之前。
memory_order_acq_rel 读-修改-写操作 同时具有 acquire 和 release 语义。
memory_order_seq_cst 任意操作 全局顺序一致性,保证所有线程看到的操作顺序一致。

通俗解释:

内存顺序 谁能用? 它干啥? 生活例子
memory_order_relaxed 所有操作 "只保证这个操作是原子的,其他随便排!" 寄一个不重要的小礼物,早到晚到无所谓
memory_order_consume 加载(读) "我读的数据,它的'依赖数据'也要可见" 你收到"快递单号",就能查到包裹位置(依赖链) ⚠️ 实际很少用,很多编译器当 acquire 处理
memory_order_acquire 加载(读) "在我之后的操作,不能跑到我前面去!" 你收到"付款成功"通知后,才能发货(不能先发货后付款)
memory_order_release 存储(写) "在我之前的操作,不能跑到我后面去!" 你必须先打包好商品,再点"已发货"(不能先点发货再打包)
memory_order_acq_rel 读-改-写(如 fetch_add) 同时具备 acquire + release 一手交钱一手交货:收钱(release)+ 给货(acquire)
memory_order_seq_cst 所有操作 "所有线程看到的操作顺序都一样!" 全国统一按下单时间发货,绝对公平(最慢但最安全)

下面我们拿最常见的用法举个例子:

假设有一个原子变量 flag 和一个数据变量 data。线程A执行以下操作:

cpp 复制代码
data = 42;                  // 普通写操作(非原子,但假设后续有同步)
atomic_flag.store(true, memory_order_release);  // 存储操作,使用 release 语义

线程B执行:

cpp 复制代码
while (!atomic_flag.load(memory_order_acquire));  // 加载操作,使用 acquire 语义
int val = data;  // 保证能看到线程A写入的 data = 42

在这里,memory_order_release 保证了线程A在存储 flag 为 true 之前的写操作(比如 data = 42)不会被重排序到存储 flag 之后,从而线程B通过 memory_order_acquire 加载 flag 为 true 后,能够正确看到 data 的更新值。

atomic实现无锁化程序与mutex的有锁化对比

cpp 复制代码
#include <atomic>         // std::atomic
#include <thread>         // std::thread
#include <vector>         // std::vector
#include<mutex>
template<typename T>
struct node
{
    T data;
    node* next;
    node(const T& data) : data(data), next(nullptr) {}
};

namespace lock_free
{
    template<typename T>
    class stack
    {
    public:
        std::atomic<node<T>*> head = nullptr;
    public:
        void push(const T& data)
        {
            node<T>* new_node = new node<T>(data);

            // 将 head 的当前值放到 new_node->next 中
            new_node->next = head.load(std::memory_order_relaxed);

             //如果线程1在进行检查的似乎也就是load后,线程2没有改变head的值,此时就返回true;
            //如果出现了线程2去修改此时除法CAS机制,把new_node->next保存head新值,然后再把new_node->next也就是head保存对应new_node值
            //正确完成链接
            while (!head.compare_exchange_weak(new_node->next, new_node,
                std::memory_order_release,
                std::memory_order_relaxed))
                ; // 循环体为空
        }
    };
}
  • 这里我们实现了一个头插模拟的栈,这里原理还是和之前的atomic的头插链表一样的,详细见注释。

下面是普通实现线程不安全,也就是不可重入的stack(后面加锁后进入):

cpp 复制代码
namespace lock
{
    template<typename T>
    class stack
    {
    public:
        node<T>* head = nullptr;

        void push(const T& data)
        {
            node<T>* new_node = new node<T>(data);
            new_node->next = head;
            head = new_node;
        }
    };
}

然后进行多线程同时插入多个节点对比无锁(atomic实现)与有锁的对比:

cpp 复制代码
int main()
{
    lock_free::stack<int> st1;
    lock::stack<int> st2;
    std::mutex mtx;
    int n = 1000000;

    auto lock_free_stack = [&st1, n] {
        for (size_t i = 0; i < n; i++)
        {
            st1.push(i);
        }
    };

    auto lock_stack = [&st2, &mtx, n] {
        for (size_t i = 0; i < n; i++)
        {
            std::lock_guard<std::mutex> lock(mtx);
            st2.push(i);
        }
    };

    // 6个线程分别使用无锁方式和有锁方式插入n个数据到栈中对比性能


    //无锁测试:
    size_t begin1 = clock();
    std::vector<std::thread> threads1;
    for (size_t i = 0; i < 6; i++)
    {
        threads1.emplace_back(lock_free_stack);
    }
    for (auto& th : threads1)
        th.join();
    size_t end1 = clock();

    std::cout << end1 - begin1 << std::endl;


    //有锁测试:
    size_t begin2 = clock();
    std::vector<std::thread> threads2;
    for (size_t i = 0; i < 6; i++)
    {
        threads2.emplace_back(lock_stack);
    }
    for (auto& th : threads2)
        th.join();
    size_t end2 = clock();

    std::cout << end2 - begin2 << std::endl;

    return 0;
}
  • 6个线程,每个都跑1000000次。

下面看下测试结果:

  • 解释下,这里发现对应的无锁甚至比有锁还慢,主要是无锁存在内存屏障、线程调度、缓存一致性,但是mutex自身存在优化行为。
  • 也就是CAS的操作多线程的时候,成功就退出循环,不成功就进行循环,这样不断完成(比的是线程的操作的速度);而mutex的是如果抢不到就去保存上下文等信息去阻塞;也就是上下文少以及线程少影响CAS操作(故看到此时CAS效率不高)。
  • 低并发、简单操作、开发效率优先 用 mutex(有锁);高并发、极轻量临界区、追求极限性能 优化后的无锁(如 CAS)可能更快,但难度大、易出错,需谨慎使用。

基于atomic的atomic_flag类型实现自旋锁

简单介绍下自旋锁

自旋锁(SpinLock)是一种锁机制,适用于锁持有时间非常短的场景。在多线程编程中,当一个线程尝试获取已被其他线程持有的锁时,自旋锁会让该线程在循环中不断检查锁是否可用,而不是进入睡眠状态。这种方式可以减少上下文切换的开销,但在锁竞争激烈或锁持有时间较长的情况下,会导致CPU资源的浪费。

对于自旋锁C++11没有实现,但是基于atomic实现个简单的自旋锁也是可以的。

代码实现如下:

cpp 复制代码
#include <atomic>         // std::atomic
#include <thread>         // std::thread
#include <vector>         // std::vector
#include<mutex>
class SpinLock
{
private:
    // ATOMIC_FLAG_INIT默认初始化为false
    std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
    void lock()
    {
        // test_and_set将内部值设置为true,并且返回之前的值
        // 第一个进来的线程将值原子的设置为true,返回false
        // 后面进来的线程将原子的值设置为true,返回true,所以卡在这里空转,
        // 直到第一个进去的线程unlock,clear,将值设置为false
        while (flag.test_and_set(std::memory_order_acquire));
    }

    void unlock()
    {
        // clear将值原子的设置为false
        flag.clear(std::memory_order_release);
    }
};

// 测试自旋锁
void worker(SpinLock& lock, int& sharedValue) {
    lock.lock();
    // 模拟一些工作
    for (int i = 0; i < 1000000; ++i) {
        ++sharedValue;
    }
    lock.unlock();
}

int main() {
    SpinLock lock;
    int sharedValue = 0;
    std::vector<std::thread> threads;

    // 创建多个线程
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back(worker, std::ref(lock), std::ref(sharedValue));
    }

    // 等待所有线程完成
    for (auto& thread : threads) {
        thread.join();
    }

    std::cout << "Final shared value: " << sharedValue << std::endl;
    return 0;
}
  • 这里其实就是如果flag_test_set的时候,flag是false,就改成true然后返回false,相当于当前线程拿到锁,之后另一个线程在来的时候就发现是true改成true,然后返回true,进入这个循环等待,等到第一个线程置成false,完成获取锁。

下面看下效果:

  • 发现多少次都是4000000次;证明多线程的时候没有出现问题(这种上下文比较简单,竞争不激烈就可以用spinlock)。

对于自旋锁特征可以概括成:

循环去等待,不舍弃cpu进行休眠来代替mutext的上下文切换+阻塞,经常用于短临界区+多核cpu使用,等待极短的时候使用(也就是上下文短),对此性能也是很高的,更加轻量。

二.本篇小结

atomic 通过硬件级原子指令保障线程安全,适用于轻量临界区;CAS与内存序灵活控制同步;无锁编程性能潜力大但需谨慎;自旋锁轻量却易耗CPU,短临界区适用。合理选择同步策略是多线程优化的关键。

相关推荐
SamDeepThinking3 小时前
synchronized 和 lock 有什么区别?
java·后端·面试
m0_587383003 小时前
无人自助拼豆系统的全栈开发方案
java·后端·struts·小程序·架构
Profile排查笔记4 小时前
浏览器多开怎么设置独立 IP?Profile、代理与核验流程
前端·人工智能·后端·自动化
tianbin9114 小时前
中小团队大模型训练落地实战:如何借助API网关降低工程负担
网络·后端
打工仔折腾 AI4 小时前
CentOS 7 单机部署 Elasticsearch 7.17.29:启动报错排查与 CRUD 验证
人工智能·后端·python·elasticsearch·性能优化·centos·jenkins
凤山老林4 小时前
Spring Boot 集成 Redis ZSet 实现实时排行榜、动态排名、同分加权与多维度榜单实战
spring boot·redis·后端
程序员cxuan5 小时前
Gemini 4 Argon 正式发布!
人工智能·后端·程序员
亚林瓜子5 小时前
Spring Boot 集成 ip2region 离线 IP 库实战指南
java·spring boot·后端·ip·ip2region
盛世宏博智慧档案6 小时前
恒湿净化一体机联动开发:档案馆温湿度阈值判断与启停控制实践
java·后端·struts·恒湿机·加湿除湿消毒净化一体机