【Linux系统】深入线程:多线程的互斥与同步原理,封装实现两种生产者消费者模型

文章目录

  • 一、互斥的概念
  • 二、线程互斥
    • [1. 锁的相关接口使用](#1. 锁的相关接口使用)
    • [2. 锁的原子性原理](#2. 锁的原子性原理)
    • [3. 锁的封装](#3. 锁的封装)
  • 三、线程同步
    • [1. 条件变量的相关接口使用](#1. 条件变量的相关接口使用)
    • [2. 条件变量的封装](#2. 条件变量的封装)
  • 四、POSIX信号量
    • [1. POSIX信号量相关接口使用](#1. POSIX信号量相关接口使用)
    • [2. 信号量的封装](#2. 信号量的封装)
  • 五、生产者消费者(cp)模型
    • [1. 生产者消费者模式介绍](#1. 生产者消费者模式介绍)
    • [2. 基于阻塞队列的cp模型](#2. 基于阻塞队列的cp模型)
    • [3. 基于环形队列的cp模型](#3. 基于环形队列的cp模型)

一、互斥的概念

我们首先要明确一些进程线程间的概念:

  • 临界资源:多线程执行流共享的资源就叫做临界资源
  • 临界区:每个线程内部访问临界资源的代码叫做临界区
  • 互斥:任何时刻,必须要保证有且仅有一个执行流进入临界区,访问临界资源,称之为互斥。互斥通常对临界资源起保护作用。
  • 原子性:不会被任何调度机制打断的操作。具有原子性的操作,只有两态,要么完成,要么未完成。

如果没有互斥,对临界资源的访问不加以保护,就会有各种的数据问题,有线程安全问题。

比如,全局变量是一种临界资源,全局变量的++--运算并不是原子的。如果某种操作只有一行汇编指令,也认为他是原子的。

一个全局变量count,执行count++,在 CPU 层面分为三步:读取 count 到寄存器、寄存器加 1、写回内存,这三步中间可被中断或线程切换打断,因此不是原子操作。

以两个线程对 count=1 各执行一次count++为例,预期结果为3,但可能发生如下错误:线程A完成读取(得到 1)后被切换,寄存器上下文数据被保存,内存中 count 仍为 1;线程 B完整执行三步,将 count 更新为 2;随后线程A恢复,基于寄存器中的旧值 1 继续执行加 1 和写回,将 count 覆写为 2,最终两次 ++ 只生效一次,丢失了一次更新。

二、线程互斥

大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程的独立栈空间内,这种情况下别的线程无法访问到这种变量。但有时候,很多变量需要在线程之间共享,完成线程之间的交互,如上面的例子。

要解决如上问题,需要做到三点:

  • 代码必须要有互斥行为:当一个线程代码进入临界区执行时,不允许其他线程进入该临界区。
  • 同时只允许一个线程进入临界区。
  • 如果线程不在临界区中执行,他不能阻止其他线程进入临界区。

要做到这三点,本质需要一把锁,Linux中称之为互斥量(mutex)!

想象共享资源是一个房间,只有一把锁和钥匙。此时来了一大堆线程进行竞争,不管怎样最终只会有一个线程得到钥匙并能开锁进入,其他的进程只能继续等待。等这个线程用完共享资源,开锁,归还钥匙,所有进程再竞争钥匙。如此,便能满足上述三点。

在代码层面,关键的就是lock(加锁)和unlock(解锁)两步操作!

为了保证我们的锁能完成线程互斥,还必须明确:

  • 加锁会导致效率降低,加锁的粒度必须足够细(影响的代码越少越好)
  • 所有线程能竞争锁,说明锁本身也是共享资源!但是锁的lock和unlock操作被设计称为了原子的,就不需要被额外保护了。
  • 访问临界资源,所有线程都必须遵守加锁解锁规则,不能有例外!
  • 没有竞争到锁的线程,都必须在Lock操作上阻塞等待!

1. 锁的相关接口使用

pthread线程库中为我们提供了锁类型 ( pthread_mutex_t )------互斥量,及其相关接口。

  • 初始化:

    当锁定义成静态、全局时,使用PTHREAD_MUTEX_INITIALIZER标志为其初始化;当锁定义在局部时,使用pthread_mutex_init函数为其初始化:第一个参数的要初始化的锁;第二个参数是相关权限,我们不必关心,填为NULL即可。

  • 销毁锁:

    使用PTHREAD_MUTEX_INITIALIZER初始化的锁不需要手动销毁。除此之外,使用pthread_mutex_destroy函数销毁锁:

    注意不要销毁一个已经加锁的互斥量;已经销毁的锁确保后面不会再加锁。

  • 加锁:

    使用pthread_mutex_lock函数为一个互斥量加锁

    如果调用lock时,该互斥量未锁,则该函数会将互斥量锁定,并且返回0表示成功;

    如果调用lock时,该互斥量已被其他线程锁定,则lock函数会阻塞,执行流被挂起,等待互斥量解锁。

  • 解锁:

    使用pthread_mutex_unlock函数为一个互斥量解锁

演示:

cpp 复制代码
#include <iostream>
#include <pthread.h>
int count = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void* routine(void* args)
{
    //加锁解锁保护临界资源
    pthread_mutex_lock(&mutex);

    //临界区
    count++;

    pthread_mutex_unlock(&mutex);

    return nullptr;
}
int main()
{
    pthread_t t1, t2;
    pthread_create(&t1, nullptr, routine, nullptr);
    pthread_create(&t2, nullptr, routine, nullptr);
    
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);

    std::cout << count << std::endl; 
    return 0;
}

2. 锁的原子性原理

上面说到,i++的操作并不是原子的,因为它翻译为汇编指令并不是一步操作。
为了实现互斥锁操作,大多数体系结构提供了swap或exchange指令,作用是把寄存器和内存单元的数据相交换,由于这是一条指令,保证了开锁解锁的原子性。

mutex看做内存中的一个变量,一开始是1。锁,就是一开始的1。在整个lock和unlock操作中,没有拷贝,自始至终只有一个1,谁有这个1,谁就有锁!

竞争锁,本质就是竞争执行xchgb指令,而因为这是一条汇编指令,因此就具有原子性!

3. 锁的封装

这份我们自己封装的锁,后面都会继续用到。

cpp 复制代码
// Mutex.hpp
#pragma once
#include<pthread.h>
class Mutex
{
public:
    Mutex()
    {
        pthread_mutex_init(&_lock, nullptr);
    }
    ~Mutex()
    {
        pthread_mutex_destroy(&_lock);
    }
    void Lock()
    {
        pthread_mutex_lock(&_lock);
    }
    void Unlock()
    {
        pthread_mutex_unlock(&_lock);
    }
    pthread_mutex_t* Ptr()
    {
        return &_lock;
    }
private:
    pthread_mutex_t _lock;
};

// RAII风格用法
class LockGuard
{
public:
    LockGuard(Mutex& lock)
        :_lockref(lock)
    {
        _lockref.Lock();
    }
    ~LockGuard()
    {
        _lockref.Unlock();
    }
private:
    Mutex& _lockref;
};

/*
后续使用我们自己封装的锁时,可以写成:

	{
	    LockGuard lock(mutex);
	    // 临界区
	    // ...
	}
	
{}划定作用域, 利用LockGuard自动调用构造析构完成加锁和解锁
*/

测试:

cpp 复制代码
#include "Mutex.hpp"
#include <iostream>
#include <pthread.h>

int count = 0;
Mutex mutex;

void* routine(void* args)
{
    // 加锁解锁保护临界资源
    {
        // 临界区
        LockGuard lg(mutex);

        count++;
    }

    return nullptr;
}
int main()
{
    pthread_t t1, t2;
    pthread_create(&t1, nullptr, routine, nullptr);
    pthread_create(&t2, nullptr, routine, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);

    std::cout << count << std::endl;
    return 0;
}

三、线程同步

回到一开始的例子,线程互斥是一大堆线程竞争唯一的一把房间钥匙。假如一个线程拿到钥匙,访问完毕,解锁后,立刻又抢到钥匙继续访问,别的线程能访问到的机会大大减少了,这也是不合理的。我们最好能让当前用完临界资源的线程,不要立刻再次访问,给别的线程机会。

所以,线程同步是指,在临界资源安全的前提下,让访问临界资源具有一定的顺序性。线程同步的完成需要条件变量(condition)。

1. 条件变量的相关接口使用

pthread线程库中为我们提供了条件变量类型 (pthread_cond_t),及其相关接口。

  • 初始化:

    当条件变量定义成静态、全局时,使用PTHREAD_COND_INITIALIZER标志为其初始化;当定义在局部时,使用pthread_cond_init函数为其初始化:第一个参数的要初始化的条件变量;第二个参数是相关权限,我们不必关心,填为NULL即可。

  • 销毁:

    使用PTHREAD_COND_INITIALIZER的条件变量不需要手动销毁,除此之外使用pthread_cond_destroy函数

  • 等待条件满足:

    使用pthread_cond_wait函数
    第一个参数表示在哪个条件变量下等待,第二个参数为锁。
    调用这个函数之前当前线程必须有锁。等待条件变量时,当前线程会自动解锁,并到这个条件变量下阻塞等待,直到被唤醒,又会自动参与竞争锁,拿到锁pthread_cond_wait函数才会返回。

  • 唤醒条件变量下等待的线程:

    pthread_cond_signal函数会唤醒这个条件变量下的一个线程,pthread_cond_broadcast函数会唤醒这个条件变量下的所有线程。

2. 条件变量的封装

cpp 复制代码
// Cond.hpp
#pragma once
#include<pthread.h>
#include"Mutex.hpp"
class Cond
{
public:
    Cond()
    {
        pthread_cond_init(&_cond, nullptr);
    }
    ~Cond()
    {
        pthread_cond_destroy(&_cond);
    }
    void Wait(Mutex& mutex)
    {
        pthread_cond_wait(&_cond, mutex.Ptr());
    }
    void Signal()
    {
        pthread_cond_signal(&_cond);
    }
    void BroadCast()
    {
        pthread_cond_broadcast(&_cond);
    }
private:
    pthread_cond_t _cond;
};

四、POSIX信号量

POSIX信号量和我们之前提到的SystemV信号量作用相同,本质是记录共享资源数量的计数器,可以用于同步操作。

互斥的本质是"独占",独占的本质是我们认为临界资源只有一份,同一时间只能有一个人占有它。所以,我们也可以把锁理解为初始值为1的信号量(二元信号量)。加锁,等同于申请信号量。申请信号量,本质是对资源的预定机制!

申请资源,计数器- -,称为P操作;
释放资源,计数器++,称为V操作。
信号量本身也是共享资源,因此P操作和V操作一定也是原子的!

1. POSIX信号量相关接口使用

POSIX信号量并不属于pthread库,由POSIX标准定义,sem_t 类型与信号量相关接口定义在<semaphore.h>头文件下。

  • 初始化信号量:
    第一个参数是信号量id;

    第二个参数pshared,为0表示信号量在线程间共享,非0表示在进程间共享;

    第三个参数为信号量的初始值。

  • 销毁信号量:

  • 申请信号量(P操作):

    这个操作会将信号量的值 -1,如果信号量的值为0,则阻塞等待直到信号量变为正数。

  • 释放信号量(V操作):这个操作会将信号量的值 +1

2. 信号量的封装

cpp 复制代码
// Sem.hpp
#pragma once
#include <semaphore.h>
class Sem
{
public:
    Sem(int init_val) // 传递信号量初始值
    {
        if (init_val >= 0)
        {
            sem_init(&_sem, 0, init_val);
        }
    }
    ~Sem()
    {
        sem_destroy(&_sem);
    }
    void P()
    {
        sem_wait(&_sem);
    }
    void V()
    {
        sem_post(&_sem);
    }

private:
    sem_t _sem;
};

五、生产者消费者(cp)模型

1. 生产者消费者模式介绍

生产者消费者模式(Producer-Consumer)就是通过一个容器来解决生产者和消费者的强耦合关系。生产者和消费者彼此之间不直接通信,而是通过队列来进行通信。生产者生产完数据不必等待消费者处理,直接扔进队列中;消费者不用找生产者要数据,而是直接从队列中取。队列就相当于一个缓冲区,用来给生产者和消费者解耦。

显然,这种模型有两种角色:生产者和消费者。也就产生了代码中必须处理的三种关系:

  • 生产者之间有互斥关系,假如队列只剩一个位置,只有一个生产者能放入数据;
  • 消费者之间有互斥关系,假如队列只有一份数据,只有一个消费者能获取;
  • 生产者与消费者之间,有同步关系,可能有互斥关系。队列满时,生产者必须等待消费者取走数据,队列空时,消费者必须等待生产者放入数据。

总结一下,生产者消费者模式有"321"原则:

  • 3种关系(生产者之间,消费者之间,生产者消费者之间)
  • 2种角色(生产者、消费者)
  • 1个交易场所(内存空间的特定数据结构)

生产者消费者模式是多线程协同的一种模式,能提高协作效率,本质是一种通信工作。它的优点是:线程之间解耦合、支持并发操作、支持忙闲不均。

2. 基于阻塞队列的cp模型

在多线程编程中阻塞队列(Block Queue)是一种常用于实现生产者消费者模型的数据结构。当阻塞队列为空时,从队列中获取元素的操作将会被阻塞;当队列为满时,往队列中存放元素的操作会被阻塞。以上的操作是基于不同的线程来说的。

我们先使用原生接口实现一遍:

cpp 复制代码
#pragam once
#include <pthread.h>
#include <queue>

const int default_cap = 5;

template <class T> 
class BlockQueue
{
public:
    BlockQueue(int cap = default_cap) : _cap(cap)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_c_cond, nullptr);
        pthread_cond_init(&_p_cond, nullptr);
    }

    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_c_cond);
        pthread_cond_destroy(&_p_cond);
    }

    // 放数据,生产者操作
    void Enqueue(T in)
    {
        pthread_mutex_lock(&_mutex);

        // 如果队列为满,此时不能生产,在条件变量下等待
        // wait函数可能会调用失败或系统原因伪唤醒,这里用while判断
        while (_queue.size() == _cap)
        {
            pthread_cond_wait(&_p_cond, &_mutex);
        }

        // 到这里队列一定有空位置
        _queue.push(in);
        // 已经生产了一个数据,可以唤醒消费者了
        pthread_cond_signal(&_c_cond);

        pthread_mutex_unlock(&_mutex);
    }

    // 消费数据,消费者操作
    void Pop(T* out)
    {
        pthread_mutex_lock(&_mutex);

        // 如果队列为空,此时不能消费,在条件变量下等待
        // wait函数可能会调用失败或系统原因伪唤醒,这里用while判断
        while (_queue.size() == 0)
        {
            pthread_cond_wait(&_c_cond, &_mutex);
        }

        // 到这里队列一定有数据
        *out = _queue.front();
        _queue.pop();
        // 取了一个数据,队列一定有一个空位置,可以唤醒生产者了
        pthread_cond_signal(&_p_cond);

        pthread_mutex_unlock(&_mutex);
    }

private:
    std::queue<T> _queue;
    int _cap;
    pthread_mutex_t _mutex;
    pthread_cond_t _c_cond;
    pthread_cond_t _p_cond;
};

再使用上面我们自己封装的锁和条件变量实现一次:

cpp 复制代码
// BlockQueue.hpp
#pragma once
#include "Cond.hpp"
#include "Mutex.hpp"
#include <queue>

const int default_cap = 5;

template <class T> class BlockQueue
{
public:
    BlockQueue(int cap = default_cap) : _cap(cap)
    {
    }

    ~BlockQueue()
    {
    }

    void Enqueue(T in)
    {
        // 加锁保护临界区
        LockGuard lg(_mutex);

        // 如果队列为满,此时不能生产,在条件变量下等待
        // wait函数可能会调用失败或系统原因伪唤醒,这里用while判断
        while (_queue.size() == _cap)
        {
            _p_cond.Wait(_mutex);
        }

        // 到这里队列一定有空位置
        _queue.push(in);
        // 现在一定至少有一个数据,可以唤醒消费者了
        _c_cond.Signal();
    }

    void Pop(T* out)
    {
        // 加锁保护临界区
        LockGuard lg(_mutex);

        // wait函数可能会调用失败或系统原因伪唤醒,这里用while判断
        while (_queue.size() == 0)
        {
            _c_cond.Wait(_mutex);
        }

        // 到这里队列一定有数据
        *out = _queue.front();
        _queue.pop();
        // 取了一个数据,队列一定有一个空位置,可以唤醒生产者了
        _p_cond.Signal();
    }

private:
    std::queue<T> _queue;
    int _cap;
    Mutex _mutex;
    Cond _c_cond;
    Cond _p_cond;
};

项目中包含上一篇文章我自己封装的线程类,如下:

cpp 复制代码
// Thread.hpp
#ifndef _THREAD_
#define _THREAD_

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

static int gnumber = 1;
using callback_t = std::function<void()>;

class Thread
{
private:
    static void* Thread_Routine(void* args)
    {
        Thread* self = static_cast<Thread*>(args);
        pthread_setname_np(self->_tid, self->_name.c_str());
        self->_task(); // 执行任务
        return nullptr;
    }

public:
    Thread(callback_t task) : _task(task), _tid(-1), _joinable(true), _result(nullptr)
    {
        _name = "NewThread-" + std::to_string(gnumber++);
    }

    void Start()
    {
        pthread_create(&_tid, nullptr, Thread_Routine, this);
    }

    void Join()
    {
        if (_joinable)
        {
            pthread_join(_tid, &_result);
            std::cout << "线程已回收" << std::endl;
        }
        else
        {
            std::cerr << "线程不可被等待" << std::endl;
        }
    }

    void Detach()
    {
        if(_joinable)
        {
            pthread_detach(_tid);
            _joinable = false;
        }
        else
        {
            std::cerr<<"线程已被分离" << std::endl;
        }
    }

    ~Thread()
    {
    }

private:
    std::string _name;
    pthread_t _tid;
    callback_t _task;
    void* _result;
    bool _joinable;
};

#endif

测试:

cpp 复制代码
#include "BlockQueue.hpp"
#include "Thread.hpp"
#include <iostream>
#include <unistd.h>

int num = 1;
Mutex lock;
BlockQueue<int> bq;

void ProductorRoutine()
{
    while(1)
    {
        char name[16];
        pthread_getname_np(pthread_self(), name, sizeof name);
        int in;
        {
            LockGuard lg(lock);
            in = num;
            num++;
        }
        bq.Enqueue(in);
        std::cout << "线程" << name << " 生产数据" << in << std::endl;
        sleep(1);
    }
}

void ConsumerRoutine()
{
    while(1)
    {
        char name[16];
        pthread_getname_np(pthread_self(), name, sizeof name);
        int out;
        bq.Pop(&out);
        std::cout << "线程" << name << " 消费数据" << out << std::endl;
        sleep(1);
    }
}

int main()
{
    Thread productor1(ProductorRoutine);
    Thread productor2(ProductorRoutine);
    Thread productor3(ProductorRoutine);

    Thread consumer1(ConsumerRoutine);
    Thread consumer2(ConsumerRoutine);

    productor1.Start();
    productor2.Start();
    productor3.Start();
    consumer1.Start();
    consumer2.Start();

    productor1.Join();
    productor2.Join();
    productor3.Join();
    consumer1.Join(); 
    consumer2.Join(); 
    return 0;
}

虽然在BlockQueue中我们好像只处理了生产者和消费者的关系,但是在一个类对象中只有一把锁,所以多生产者之间、多消费者之间,也是天然满足互斥关系的!

因为打印操作我们没有加保护,可能会出现混乱,但是主要逻辑没有问题!

3. 基于环形队列的cp模型

环形队列(Ring Queue)是一种首尾相连的队列,他有一个队头head位置和队尾tail位置。

当队列为空、为满时,head和tail的位置相同,必须互斥访问 。队列为空不能取数据,队列为满不能存数据;
当队列有元素且不为满时,head和tail一定在不同的位置,可以并发访问环形队列。
我们可以用数组模拟,用模运算模拟环状特性。

在基于环形队列的生产者消费者模型中,tail位置代表生产者放入新数据的位置,head位置代表消费者取数据的位置。
我们可以用两个信号量分别代表生产者和消费者的资源数量。对于生产者,"空位置"是他需要的资源;对于消费者,"数据"是他需要的资源。只有能申请到信号量,预定到资源,才能进一步操作。

直接使用我们自己封装的信号量和锁来完成:

cpp 复制代码
// RingQueue.hpp
#pragma once
#include "Mutex.hpp"
#include "Sem.hpp"
#include <vector>

const int default_cap = 5;

template <class T> class RingQueue
{
public:
    RingQueue(int cap = default_cap) 
        : _cap(cap),
          _rq(cap),
          _c_step(0),
          _p_step(0),
          _data(0),
          _blank(cap)
    {}
    ~RingQueue()
    {}

    void Enqueue(T& in)
    {
        // 生产者放数据,需要申请一个空位置
        _blank.P();

        {
            LockGuard lg(_p_mutex);
            _rq[_p_step] = in;
            _p_step++;
            _p_step %= _cap;
        }

        // 此时多了一个数据,_data信号量可以释放
        _data.V();
    }

    void Pop(T* out)
    {
        // 消费者取数据,需要申请一个数据
        _data.P();

        {
            LockGuard lg(_c_mutex);
            *out = _rq[_c_step];
            _c_step++;
            _c_step %= _cap;
        }

        // 此时多了一个空位置,_blank信号量可以释放
        _blank.V();
    }

private:
    std::vector<T> _rq;
    int _cap;    // 最大容量
    int _c_step; // 消费者取数据位置
    int _p_step; // 生产者放数据位置

    Sem _data;   // 消费者需要的数据资源, 初始为0
    Sem _blank;  // 生产者需要的空格资源, 初始为cap

    Mutex _c_mutex; // 维护消费者之间的互斥
    Mutex _p_mutex; // 维护生产者之间的互斥
};

测试:

cpp 复制代码
#include "RingQueue.hpp"
#include "Thread.hpp"
#include <iostream>
#include <unistd.h>

int num = 1;
Mutex lock;
RingQueue<int> rq;

void ProductorRoutine()
{
    while(1)
    {
        char name[16];
        pthread_getname_np(pthread_self(), name, sizeof name);
        int in;
        {
            LockGuard lg(lock);
            in = num;
            num++;
        }
        rq.Enqueue(in);
        std::cout << "线程" << name << " 生产数据" << in << std::endl;
        sleep(1);
    }
}

void ConsumerRoutine()
{
    while(1)
    {
        char name[16];
        pthread_getname_np(pthread_self(), name, sizeof name);
        int out;
        rq.Pop(&out);
        std::cout << "线程" << name << " 消费数据" << out << std::endl;
        sleep(1);
    }
}

int main()
{
    Thread productor1(ProductorRoutine);
    Thread productor2(ProductorRoutine);
    Thread productor3(ProductorRoutine);

    Thread consumer1(ConsumerRoutine);
    Thread consumer2(ConsumerRoutine);

    productor1.Start();
    productor2.Start();
    productor3.Start();
    consumer1.Start();
    consumer2.Start();

    productor1.Join();
    productor2.Join();
    productor3.Join();
    consumer1.Join(); 
    consumer2.Join(); 
    return 0;
}

逻辑没有问题!还是一样的打印问题,也可以选择给打印语句加锁解决,就不再演示了

本文代码均提交在我的github仓库中。

本文完,感谢阅读!

相关推荐
weixin_408717772 小时前
如何导入带系统变量修改的SQL_确保SUPER权限并规避只读变量报错
jvm·数据库·python
有梦想的牛牛2 小时前
GPT-6 能力畅想:当 AI 跨越“理解”走向“共生”
人工智能·gpt
.小小陈.2 小时前
深度拆解 Linux 进程间通信(IPC):从管道到 System V 全链路详解
linux·服务器·网络·学习
m0_678485452 小时前
c++怎么编写多线程安全的跨平台文件日志库_无锁队列与异步IO【附源码】
jvm·数据库·python
m0_746752302 小时前
PHP源码运行时风扇狂转怎么办_硬件温控调优方法【说明】
jvm·数据库·python
EverestVIP2 小时前
C++成员指针在库设计中的实际案例
c++
8Qi82 小时前
Elasticsearch实战篇:索引库、文档与JavaRestClient操作指南
java·大数据·elasticsearch·搜索引擎·微服务·架构·springcloud
米猴设计师2 小时前
PS电商详情页高效制作:Nano Banana一键生成电商高转化套图(附实操教程)
大数据·图像处理·人工智能·ai·aigc·startai·banana修图
财经资讯数据_灵砚智能2 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(日间)2026年4月17日
人工智能·python·信息可视化·自然语言处理·ai编程