当前位置: 首页 > news >正文

【Linux】进程间同步与互斥(下)

目录

一、线程池

1.1 日志与策略模式

1.2 线程池设计

1.3 线程安全的单例模式

1.3.1 单例模式的特点

1.3.2 饿汉实现方式和懒汉实现方式

1.3.3 饿汉实现单例模式

1.3.4 懒汉实现单例模式

1.3.5 懒汉实现单例模式(线程安全版)

1.4 单例式线程池

二、线程安全和重入问题

三、常见锁概念

3.1 死锁

3.2 死锁四个必要条件

3.3 避免死锁

四、STL、智能指针和线程安全

4.1 STL中的容器是否是线程安全的

4.2 智能指针是否是线程安全的


一、线程池

下面开始,我们将结合我们之前做的所有封装,进行一个线程池设计。在写之前,我们要做如下准备:

  • 准备线程的封装
  • 准备锁和条件变量的封装
  • 引入日志,对线程进行封装

1.1 日志与策略模式

什么是设计模式

IT行业这么火,涌入的人很多。俗话说林子大了什么鸟都有,大佬和菜鸟的两极分化越来越严重,为了让菜鸡们不太拖大佬的后腿,于是大佬们针对一些经典的常见的场景,给定一些对应的解决方案,这个就是 设计模式

日志认识

计算机中的日志是记录系统和软件运行中发生事件的文件,主要作用是监控运行状态、记录异常信息,帮助快速定位问题并且支持程序员进行问题修复。它是系统维护、故障排查和安全管理的重要工具。

日志格式以下几个指标是必须得有的:

  • 时间戳
  • 日志等级
  • 日志内容

以下几个指标是可选的:

  • 文件名行号
  • 进程,线程相关id信息

日志有现成的解决方案,如:spdlog、glog、Boost.Log、log4cxx等等,我们依旧采用自定义的方式。

这里我们采用设计模式 - 策略模式来进行日志的设计,我们想要设计的日志格式如下:

[可读性很好的时间] [日志等级] [进程pid] [打印对应日志的文件名][行号] - 消息内容,支持可变参数

[2025-10-11 12:27:03] [DEBUG] [202938] [main.cc] [1] - hello world

[2025-10-11 12:27:03] [INFO] [202938] [main.cc] [2] - hello world

[2025-10-11 12:27:03] [WARNING] [202938] [main.cc] [3] - hello world

[2025-10-11 12:27:03] [ERROR] [202938] [main.cc] [4] - hello world

[2025-10-11 12:27:03] [FATAL] [202938] [main.cc] [5] - hello world

// Log.hpp#pragma once#include <iostream>
#include <string>
#include <fstream>
#include <ctime>
#include <memory>
#include <sstream>
#include <filesystem> // C++17
#include <unistd.h>
#include "Lock.hpp"namespace LogModule
{using namespace LockModule;// 默认路径和日志名称const std::string defaultpath = "./log/";const std::string defaultname = "log.txt";// 日志等级enum LogLevel{DEBUG,INFO,WARNING,ERROR,FATAL};// 日志等级转化成字符串std::string LogLevelToString(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 "UNKNOW";}}// 根据时间戳,获取时间信息std::string GetCurTime() {time_t tm = time(nullptr);struct tm curr;localtime_r(&tm, &curr);char timebuffer[64];snprintf(timebuffer, sizeof(timebuffer), "%4d-%02d-%02d %02d:%02d:%02d",curr.tm_year + 1900,curr.tm_mon,curr.tm_mday,curr.tm_hour,curr.tm_min,curr.tm_sec);return timebuffer;}// 策略模式,策略接口class LogStrategy{public:virtual ~LogStrategy() = default;virtual void SyncLog(const std::string& message) = 0; // 不同模式的核心是刷新方式不同};// 控制台策略,日志只向显示器打印,方便调试class ConsoleLogStrategy : public LogStrategy{public:void SyncLog(const std::string& message) {LockGuard guard(_mutex);std::cout << message << std::endl;}~ConsoleLogStrategy(){}private:Mutex _mutex; // 显示器也是临界资源,保证输出线程安全};// 文件日志class FileLogStrategy : public LogStrategy{public:// 构造函数,建立指定的目录结构和文件结构FileLogStrategy(const std::string filepath = defaultpath, const std::string filename = defaultname):_filepath(filepath), _filename(filename){LockGuard guard(_mutex);if(std::filesystem::exists(_filepath))return;try{std::filesystem::create_directory(_filepath);}catch(const std::filesystem::filesystem_error &e){std::cerr << e.what() << '\n';}}void SyncLog(const std::string& message) {LockGuard guard(_mutex);std::string log = _filepath + _filename;std::ofstream out(log.c_str(), std::ios::app); // 已追加的方式打开if(!out.is_open())return;out << message << "\n";out.close();}~FileLogStrategy(){}private:std::string _filepath;std::string _filename;Mutex _mutex;};// 具体的日志类class Logger{public:Logger() {UseConsoleLogStrategy(); // 默认使用控制台模式}void UseConsoleLogStrategy() {_strategy = std::make_unique<ConsoleLogStrategy>();}void UseFileLogStrategy() {_strategy = std::make_unique<FileLogStrategy>();}// 内部类,实现RAII风格的日志格式化和刷新// 这个LogMessage,表示一条完整的日志对象class LogMessage{public:LogMessage(LogLevel level, const std::string filename, int line, Logger& log):_level(level), _filename(filename), _line(line), _log(log), _curr_time(GetCurTime()), _pid(getpid()){std::stringstream ss;ss << "[" << _curr_time << "]"<< "[" << LogLevelToString(_level) << "]"<< "[" << _pid << "]"<< "[" << _filename << "]"<< "[" << _line << "]" << " - ";_log_info = ss.str();}// 重载<<, 支持C++风格的日志格式,使用模板,支持任意类型template<typename T>LogMessage& operator<<(const T& info) {std::stringstream ss;ss << info;_log_info += ss.str();return *this; // 返回当前LogMessage对象,方便再次调用<<}~LogMessage() {if(_log._strategy)_log._strategy->SyncLog(_log_info);}private:std::string _curr_time; // 当前时间LogLevel _level;        // 日志等级pid_t _pid;             // 进程idstd::string _filename;  // 运行文件名int _line;              // 行号Logger& _log;           // 引用外部类,方便使用策略刷新std::string _log_info;  // 一条完整的日志信息};// 故意拷贝,形成LogMessage临时对象,后续再被<<时,会被持续调用// 直到完成输入,才会自动析构,至此完成了日志的刷新// 同时,形成的临时对象内包含独立的日志数据// 之后采用宏替换,支持文件名和行号的获取LogMessage operator()(LogLevel level, const std::string filename, int line) {return LogMessage(level, filename, line, *this);}private:std::unique_ptr<LogStrategy> _strategy;};// 定义全局的Logger对象Logger logger;// 使用宏定义,可以进行代码插入,方便随时获取文件名和行号#define LOG(type) logger(type, __FILE__, __LINE__)// 提供选择使用何种日志策略的方法#define ENABLE_CONSOLE_LOG_STRATEGY() logger.UseConsoleLogStrategy()#define ENABLE_FILE_LOG_STRATEGY() logger.UseFileLogStrategy()
}
// test.cc#include <iostream>
#include "Log.hpp"using namespace LogModule;int main()
{// ENABLE_CONSOLE_LOG_STRATEGY();LOG(DEBUG) << "hello world!";LOG(INFO) << "hello world!";LOG(WARNING) << "hello world!";LOG(ERROR) << "hello world!";LOG(FATAL) << "hello world!";LOG(DEBUG) << "hello, " << 3.1415926 << 'a' << 'b' << 'c';return 0;
}

// test.cc 日志写入文件#include <iostream>
#include "Log.hpp"using namespace LogModule;int main()
{ENABLE_FILE_LOG_STRATEGY();LOG(DEBUG) << "hello world!";LOG(INFO) << "hello world!";LOG(WARNING) << "hello world!";LOG(ERROR) << "hello world!";LOG(FATAL) << "hello world!";LOG(DEBUG) << "hello, " << 3.1415926 << 'a' << 'b' << 'c';return 0;
}

1.2 线程池设计

线程池

一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能保证内核的充分利用,还能防止过度调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。

线程池的应用场景:

  • 需要大量的线程来完成任务,且完成任务的时间比较短。比如WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,且任务数量巨大,你可以想象一个热门网站的点击次数。但对于长时间的任务,比如一个Telnet的连接请求,线程池的优点就不明显了。因为Telnet的会话时间比线程的创建销毁时间大太多了。
  • 对性能要求苛刻的应用,比如要求服务器要迅速响应客户的请求。
  • 接受突发性的大量请求,但不至于是服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池的情况下,将产生大量线程,虽然理论上大多数操作系统线程数目最大值不是问题,但是短时间内产生大量线程可能使内存达到极限,出现错误。

线程池的种类:

  1. 创建固定数量线程池,循环从任务队列中获取任务对象,获取到任务对象后,执行任务对象中的任务接口。
  2. 浮动线程池,其他同上。

这里,我们选择第一种线程池实现。

// ThreadPool.hpp#pragma once#include <iostream>
#include <vector>
#include <queue>
#include <memory>
#include "Log.hpp"      // 引入自己的日志
#include "Thread.hpp"   // 引入自己的线程
#include "Lock.hpp"     // 引入自己的锁
#include "Cond.hpp"     // 引入信号量using namespace LogModule;
using namespace ThreadModule;
using namespace LockModule;
using namespace CondModule;const int gdefaultthreadnum = 6;template<class T>
class ThreadPool
{
public:ThreadPool(int threadnum = gdefaultthreadnum):_threadnum(threadnum), _waitnum(0), _running(false){LOG(LogLevel::INFO) << "ThreadPool Construct()";}void InitThreadPool() {for(int i = 0; i < _threadnum; i++) {_threads.emplace_back(std::bind(&ThreadPool::Task, this));LOG(LogLevel::INFO) << "Create thread: " << _threads.back().Name();}}void Stop() {_mutex.Lock();_running = false;_cond.NotifyAll();_mutex.Unlock();LOG(LogLevel::INFO) << "线程池退出中...";}void Start() {_running = true;for(auto& thread : _threads) {thread.Start();LOG(LogLevel::INFO) << "Start thread: " << thread.Name();}}bool Push(const T& t) {_mutex.Lock();if(_running) {_tasks.push(t);if(_waitnum > 0)_cond.Notify();_mutex.Unlock();LOG(LogLevel::INFO) << "add a task success";return true;}_mutex.Unlock();return false;}void Wait() {for(auto& thread : _threads) {thread.Join();LOG(LogLevel::INFO) << "Wait thread: " << thread.Name() << ", success";}}~ThreadPool() {}
private:void Task() {while(true) {_mutex.Lock();while(_tasks.empty() && _running) {_waitnum++;_cond.Wait(_mutex);_waitnum--;}// 线程池已退出,将剩下的任务执行完,线程也要退出if(_tasks.empty() && _running == false) {_mutex.Unlock();break;}T& t = _tasks.front();_tasks.pop();_mutex.Unlock();LOG(LogLevel::DEBUG) << "get a task!";t();}}
private:int _threadnum;std::vector<Thread> _threads;std::queue<T> _tasks;Mutex _mutex;Cond _cond;int _waitnum;bool _running;
};
// test.cc#include "ThreadPool.hpp"
#include <functional>using task_t = std::function<void()>;void Test_Task() {std::cout << "我是一个测试线程池的任务" << std::endl;
}int main()
{task_t t = Test_Task;ThreadPool<task_t> tp;tp.InitThreadPool();tp.Start();int cnt = 9;while(cnt--) {tp.Push(t);sleep(1);}tp.Stop();tp.Wait();return 0;
}

1.3 线程安全的单例模式

1.3.1 单例模式的特点

某些类,只应该具有一个对象(实例),也称为单例。

在很多服务器开发场景中,经常要让服务器加载很多数据(上百G)到内存中。此时往往要用一个单例的类来管理这些数据。

1.3.2 饿汉实现方式和懒汉实现方式

我们以洗碗作为例子:

吃完饭,立刻洗碗,这就是饿汉方式,因为下一次吃饭就可以立刻拿碗直接吃了。

吃完饭,先把碗放下,等下一次要吃饭时再洗碗吃饭,这就是懒汉方式。

懒汉方式最核心的思想是“延时加载”,从而能够优化服务器启动速度。

1.3.3 饿汉实现单例模式

template <typename T>
class Singleton
{static T data;public:static T *GetInstance(){return &data;}
};

只能通过 Singleton 这个包装类来使用 T 对象,则一个进程中只有一个 T 对象的实例。

1.3.4 懒汉实现单例模式

template <typename T>
class Singleton
{static T *inst;public:static T *GetInstance(){if (inst == NULL){inst = new T();}return inst;}
};

存在一个严重的问题,线程不安全。

第一次调用 GetInstance 时如果两个线程同时调用,可能会创建出两份 T 对象的实例。

但是后续再次调用就没有问题了。

1.3.5 懒汉实现单例模式(线程安全版)

// 懒汉模式, 线程安全
template <typename T>
class Singleton
{volatile static T *inst; // 需要设置 volatile 关键字, 否则可能被编译器优化.static std::mutex lock;public:static T *GetInstance(){if (inst == NULL){                // 双重判定空指针, 降低锁冲突的概率, 提⾼性能.lock.lock(); // 使⽤互斥锁, 保证多线程情况下也只调⽤⼀次 new.if (inst == NULL){inst = new T();}lock.unlock();}return inst;}
};

注意事项:

  1. 加锁解锁的位置
  2. 双重 if 判定,避免不必要的锁竞争
  3. volatile 关键字,防止过度优化

1.4 单例式线程池

// ThreadPool.hpp 单例模式#pragma once#include <iostream>
#include <vector>
#include <queue>
#include <memory>
#include "Log.hpp"      // 引入自己的日志
#include "Thread.hpp"   // 引入自己的线程
#include "Lock.hpp"     // 引入自己的锁
#include "Cond.hpp"     // 引入信号量using namespace LogModule;
using namespace ThreadModule;
using namespace LockModule;
using namespace CondModule;const int gdefaultthreadnum = 6;template<class T>
class ThreadPool
{
public:void Stop() {_mutex.Lock();_running = false;_cond.NotifyAll();_mutex.Unlock();LOG(LogLevel::INFO) << "线程池退出中...";}bool Push(const T& t) {_mutex.Lock();if(_running) {_tasks.push(t);if(_waitnum > 0)_cond.Notify();_mutex.Unlock();LOG(LogLevel::INFO) << "add a task success";return true;}_mutex.Unlock();return false;}void Wait() {for(auto& thread : _threads) {thread.Join();LOG(LogLevel::INFO) << "Wait thread: " << thread.Name() << ", success";}}~ThreadPool() {}// 禁用拷贝和赋值ThreadPool(const ThreadPool<T>&) = delete;ThreadPool& operator=(const ThreadPool<T>&) = delete;static ThreadPool<T>* GetInstance(int threadnum = gdefaultthreadnum) {if(_instance == nullptr) {LockGuard guard(_lock);if(_instance == nullptr) {_instance = new ThreadPool<T>(threadnum);_instance->InitThreadPool();_instance->Start();LOG(LogLevel::INFO) << "Create threadpool instance...";return _instance;}}LOG(LogLevel::INFO) << "get threadpool instance...";return _instance;}
private:ThreadPool(int threadnum = gdefaultthreadnum):_threadnum(threadnum), _waitnum(0), _running(false){LOG(LogLevel::INFO) << "ThreadPool Construct()";}void InitThreadPool() {for(int i = 0; i < _threadnum; i++) {_threads.emplace_back(std::bind(&ThreadPool::Task, this));LOG(LogLevel::INFO) << "Create thread: " << _threads.back().Name();}}void Start() {_running = true;for(auto& thread : _threads) {thread.Start();LOG(LogLevel::INFO) << "Start thread: " << thread.Name();}}void Task() {while(true) {_mutex.Lock();while(_tasks.empty() && _running) {_waitnum++;_cond.Wait(_mutex);_waitnum--;}// 线程池已退出,将剩下的任务执行完,线程也要退出if(_tasks.empty() && _running == false) {_mutex.Unlock();break;}T& t = _tasks.front();_tasks.pop();_mutex.Unlock();LOG(LogLevel::DEBUG) << "get a task!";t();}}
private:int _threadnum;std::vector<Thread> _threads;std::queue<T> _tasks;Mutex _mutex;Cond _cond;int _waitnum;bool _running;static ThreadPool<T>* _instance;static Mutex _lock;
};template<typename T>
ThreadPool<T>* ThreadPool<T>::_instance = nullptr;template<typename T>
Mutex ThreadPool<T>::_lock;
// test.cc#include "ThreadPool.hpp"
#include <functional>using task_t = std::function<void()>;void Test_Task()
{std::cout << "我是一个测试线程池的任务" << std::endl;
}int main()
{task_t t = Test_Task;int cnt = 9;while(cnt--) {ThreadPool<task_t>::GetInstance()->Push(t);sleep(1);}ThreadPool<task_t>::GetInstance()->Stop();ThreadPool<task_t>::GetInstance()->Wait();return 0;
}

二、线程安全和重入问题

概念

线程安全:就是多个线程在访问共享资源时,能够正确执行,不会互相干扰或者破坏彼此的执行结果。一般而言,多个线程并发同一段只有局部变量的代码时,不会出现不同的结果。但是对全局变量或静态变量进行操作,并且没有锁保护的情况下,容易出现该问题。

重入:同一个函数被不同的执行流调用,当前一个执行流还没有执行完,就有其他执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者问题,则该函数被称为可重入函数,否则,是不可重入函数。

到现在,我们已经能理解重入其实分为两种情况:

  • 多线程重入函数
  • 信号导致一个执行流重复进入函数

常见的线程不安全的情况:

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

常见不可重入的情况:

  • 调用了 malloc/free 函数,因为 malloc 函数是用全局链表来管理堆的
  • 调用了标准 I/O 库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构
  • 可重入函数体内使用了静态的数据结构

常见的线程安全的情况:

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般而言这些线程是安全的
  • 类或者接口对于线程来说都是原子操作
  • 多个线程之间的切换不会导致该接口的执行结果出现二义性

常见可重入的情况:

  • 不使用全局变量或静态变量
  • 不使用 malloc 或者 new 开辟出的空间
  • 不调用不可重入函数
  • 不返回静态或全局数据,所有数据都由函数的调用者提供
  • 使用本地数据,或者通过制造全局数据的本地拷贝来保护全局数据

可重入与线程安全的联系:

  • 函数是可重入的,那就是线程安全的。
  • 函数是不可重入的,那就不能由多个线程来使用,有可能引发线程安全的问题
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全的也不是可重入的

可重入与线程安全的区别:

  • 可重入函数是线程安全函数的一种
  • 线程安全不一定是可重入的,而可重入函数一定是线程安全的
  • 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的

注意:

  • 如果不考虑 信号导致一个执行流重复进入函数 这种重入情况,线程安全和重入在安全角度不做区分。
  • 但是线程安全侧重说明线程访问公共资源的安全情况,表现的是并发线程的特点。
  • 可重入描述的是一个函数能否被重复进入,表现的是函数的特点。

三、常见锁概念

3.1 死锁

  • 死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待的状态。
  • 为了方便表述,假设现在线程A,线程B必须同时持有锁1和锁2,才能进行后续资源的访问。

申请一把锁是原子的,但是申请两把锁就不一定了。

造成的结果是

3.2 死锁四个必要条件

  • 互斥条件:一个资源每次只能被一个执行流使用
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放

  • 不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺

  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

3.3 避免死锁

破坏死锁的四个必要条件:

  • 破坏循环等待条件问题:资源一次性分配,使用超时机制、加锁顺序一致
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
#include <unistd.h>// 定义两个共享资源(整数变量)和两个互斥锁
int shared_resource1 = 0;
int shared_resource2 = 0;
std::mutex mtx1, mtx2;// ⼀个函数,同时访问两个共享资源
void access_shared_resources()
{std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);// // 使⽤ std::lock 同时锁定两个互斥锁std::lock(lock1, lock2);// 现在两个互斥锁都已锁定,可以安全地访问共享资源int cnt = 10000;while (cnt){++shared_resource1;++shared_resource2;cnt--;}// 当离开 access_shared_resources 的作⽤域时,lock1 和 lock2 的析构函数会被⾃动调⽤// 这会导致它们各⾃的互斥量被⾃动解锁
}
// 模拟多线程同时访问共享资源的场景
void simulate_concurrent_access()
{std::vector<std::thread> threads;// 创建多个线程来模拟并发访问for (int i = 0; i < 10; ++i){threads.emplace_back(access_shared_resources);}// 等待所有线程完成for (auto &thread : threads){thread.join();}// 输出共享资源的最终状态std::cout << "Shared Resource 1: " << shared_resource1 << std::endl;std::cout << "Shared Resource 2: " << shared_resource2 << std::endl;
}
int main()
{simulate_concurrent_access();return 0;
}

  • 避免锁未释放的场景

四、STL、智能指针和线程安全

4.1 STL中的容器是否是线程安全的

不是。

原因是,STL的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。

而且对于不同的容器,加锁方式不同,性能可能也不同(例如hash表的锁表和锁桶)。

因此,STL默认不是线程安全。如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。

4.2 智能指针是否是线程安全的

对于 unique_ptr,由于只是在当前代码块范围生效,因此不涉及到线程安全问题。

对于 shared_ptr,多个对象需要公用一个引用计数变量,所以会存在线程安全问题。但是标准库实现的时候考虑到了这个问题,基于原子操作(CAS)的方式保证 shared_ptr 能够高效,原子的操作引用计数。

http://www.dtcms.com/a/474113.html

相关文章:

  • 现成的手机网站做APP手机网站开发教程pdf
  • 【栈】5. 验证栈序列(medium)
  • Leetcode之 Hot 100
  • 建立能网上交易的网站多少钱wordpress调取多个分类文章
  • MySQL 索引:原理、分类与操作指南
  • Blender机箱盒体门窗铰链生成器资产预设 Hingegenious
  • 网站托管就业做美食有哪些网站
  • 神经符号AI的深度探索:从原理到实践的全景指南
  • 零食网站建设规划书建行输了三次密码卡锁怎么解
  • Python代码示例
  • 济南市历下区建设局官方网站wordpress高级套餐
  • ALLEGRO X APD版图单独显示某一网络的方法
  • 计算机网络基础篇——如何学习计算机网络?
  • 电子商务网站建设的总体设计wordpress dux主题5.0版本
  • 《jEasyUI 创建简单的菜单》
  • AI【前端浅学】
  • 怎么设置网站名称失眠先生 wordpress
  • 低空物流+韧性供应链:技术架构与企业级落地的开发实践指南
  • Quartus II软件安装步骤(附安装包)Quartus II 18超详细下载安装教程
  • 动规——棋圣匹配
  • 侵入别人的网站怎么做我的家乡网页制作步骤
  • Thonny(Python IDE)下载和安装教程(附安装包)
  • Fastdfs_MinIO_腾讯COS_具体逻辑解析
  • SDCC下载和安装图文教程(附安装包,C语言编译器)
  • 用python做的电商网站常德网站建设费用
  • LSTM新架构论文分享5
  • 自然语言处理分享系列-词向量空间中的高效表示估计(三)
  • 网页做网站的尺寸最新永久ae86tv最新
  • java的StringJoiner用法
  • 作业、工时、工作中心的一些配置杂记