由libunifex来看Executor的任务构建

news2024/11/18 1:29:43

前言

之前的一篇文章讲述了future的优缺点,以及future的组合性,其中也讲述了构建任务DAG一些问题,同时给出了比较好的方案则是Executor。
Executor还未进入标准(C++23),Executor拥有惰性构建及良好的抽象模型来构建任务DAG,libunifex则给出了相当具有标准的实现,我们也借助libunifex的简短的代码来看下构建任务DAG的便利性。

Executor

在讲述例子之前,我们先来了解下Executor的一些概念
Executor有sender和receiver的概念,sender则是当任务构建的存放对象,使用sync_wait等函数去执行构建的任务(sender)时,会创建一个receiver来和sender进行连接,同时连接过程也是sender的拆解和receiver构建的过程。这里sender拆解是因为构建任务的过程中,一个任务就是一个sender,为了避免类型擦除,后一个任务就会拥有前一个任务创建的sender,举例来说,via() -> then -> then, 这样的一个流程时,构建的sender则为,then_sender<then_sender<just_sender>>,但是实际运行时开始则是运行最里边sender,那么就会进行拆解构建receiver,即为via_receiver<then_receiver<then_receiver>>,把这个receiver拿去一层一层的运行即可。
理解起来可能不那么明朗,使用libunifex的例子来看下。

libunifex的例子

#include <unifex/scheduler_concepts.hpp>
#include <unifex/single_thread_context.hpp>
#include <unifex/sync_wait.hpp>
#include <unifex/then.hpp>
#include <unifex/via.hpp>
#include <unifex/just.hpp>

#include <iostream>

using namespace unifex;

int main() {
  single_thread_context context;
  auto sender = via(context.get_scheduler(), just(1)) | then([](int i) {
    return i + 45;
  });
  auto ret = sync_wait_r<int>(std::move(sender));
  std::cout << "ret is: " << *ret << std::endl;

  return 0;
}

sender构造

这是一个完整的例子来表述任务的依赖关系,首先看到我们初始化变量single_thread_context是一个调度器的context,libunifex是以conetxt内部来封装调度器,便于隐藏cpu或者gpu等调度运行细节。
我们就先来了解下single_thread_context的一些重要的部分:

namespace _single_thread {
class context {
  manual_event_loop loop_;
  std::thread thread_;

public:
  context() : loop_(), thread_([this] { loop_.run(); }) {}

  ~context() {
    loop_.stop();
    thread_.join();
  }

  auto get_scheduler() noexcept {
    return loop_.get_scheduler();
  }

  std::thread::id get_thread_id() const noexcept {
    return thread_.get_id();
  }
};
} // namespace _single_thread

using single_thread_context = _single_thread::context;

最后一行可以看到single_thread_context就是context这个类,包含一个线程成员和manual_event_loop成员,构造时就开启一个线程运行loop的run函数。调度器也就是loop的调度器。这么看来主要的核心代码还是在manual_event_loop中实现。但是可以知道context的主要作用是提供一个get_scheduler的抽象接口,便于能够使用真正的调度器。

继续来看下manual_event_loop类:

class context {
 public:
  class scheduler {
    class schedule_task {
        // ...
      template <typename Receiver>
      operation<Receiver> connect(Receiver&& receiver) const {
        return operation<Receiver>{(Receiver &&) receiver, loop_};
      }

      explicit schedule_task(context* loop) noexcept
        : loop_(loop)
      {}

      context* const loop_;
    };

    explicit scheduler(context* loop) noexcept : loop_(loop) {}

   public:
    schedule_task schedule() const noexcept {
      return schedule_task{loop_};
    }

   private:
    context* loop_;
  };

  scheduler get_scheduler() {
    return scheduler{this};
  }

  void run();
  void stop();
  void enqueue(task_base* task);
};

using manual_event_loop = _manual_event_loop::context;

这里的最后一行代码也可以看出来manual_event_loop就是_manual_event_loop下的context。context中要表述的东西有点多,先大致讲解下流程,后边我们一步一步分析代码就明朗了。首先可以看到也是我们知道的有一个调度器scheduler类,而scheduler中又包含schedule_task,在调用get_scheduler时返回scheduler的对象,某一时刻需要执行调度任务时会调用scheduler类中schedule()函数返回schedule_task,并使用schedule_task调用connect函数得到operation这个对象,operation继承自task_base,那就能知道operation本身可以归属为一个task,然后会调用enqueue将operation放到manual_event_loo也就是_manual_event_loop下的context的任务队列中,run函数检查到有任务则会直接运行该任务。知道别处调用stop就会推出这个调度器的运行。

通过以上讲述,大家可能大概明白调度器的一个运行逻辑,我们也先回到开头的例子中进行分析。

auto sender = via(context.get_scheduler(), just(1)) | then([](int i) {
    return i + 45;
});

来看这句代码,使用竖线来表述运行的前后关系,这也是c++20中一个调用的特性,写成我们熟知的形式就是:

then(via(context.get_scheduler(), just(1)), [](int i){
    return i + 45;
});

如此便可以知道,首先调用context.get_scheduler()和just传递给via,via计算出结果再传递给then。那我们首先看你just(1)是做了什么?

namespace _just_cpo {
  inline const struct just_fn {
    template <typename... Values>
    constexpr auto operator()(Values&&... values) const {
      return _just::sender<Values...>{std::in_place, (Values&&)values...};
    }
  } just{};
} // namespace _just_cpo
using _just_cpo::just;

这里使用了cpo的一种技术手段来实现just的函数调用,cpo这里我们不展开来讲,这里我们只需要知道just函数最终会调用到operator()中,然后可以知道该函数仅仅是构造了一个_just::sender并将我们的传递的参数1保存下来。看到这里我们已经学会了一个sender,也就是最简单just_sender,那就是明白这个just主要作用就是构造一个sender并保存参数,已便于给后边then的形参使用。

继续看下via的调用:

namespace _via_cpo {
  inline const struct _fn {
    template (typename Scheduler, typename Sender)
    auto operator()(Scheduler&& sched, Sender&& send) const {
      return _via::sender<Sender, schedule_result_t<Scheduler>>{
          (Sender&&) send,
          schedule(sched)};
    }
  } via{};
} // namespace _via_cpo

using _via_cpo::via;

代码中同理可知,调用operator()函数并传递Scheduler和Sender参数,同样也是会构造一个_via::sender,会将sender和schedule(sched)保存,这里看到会调用到schedule函数,同样使用cpo的手段调用到我们的manual_event_loop的Scheduler中的schedule函数。上文我们可以知道schedule函数会返回schedule_task对象。那也就是说这里_via::sender会保存sender和schedule_task,不过在_via::sender中名称有一点变化,传递进来的sender对象称为前驱Predecessor,schedule_task被称为后继Successor。此时我们构造出来的完整的类型就是:_via::sender<_just::sender<Values…>, _manual_event_loop::context::scheduler::schedule_task>。

然后继续看下then函数的调用:

template(typename Sender, typename Func)
auto operator()(Sender&& predecessor, Func&& func) const {
    return _then::sender<Sender, Func>{(Sender &&) predecessor, (Func &&) func};
}

这里除了使用cpo,同时还使用了tag_invoke的技术手段,cpo或者tag_invoke可以帮助找到实际的调用的函数是哪个(候选的函数名字基本一致),同样我们也不展开tag_invoke.
我们仅仅是告知大家会调用到这个函数,相信大家也猜到了这里也是会构造一个sender出来(_then::sender),会保存我们刚刚生成_via::sender对象以及自己带的函数对象。

小结

到这里我们就完成sender的构造:_then::sender<_via::sender<_just::sender<Values…>, _manual_event_loop::context::scheduler::schedule_task>, Func>。哇,好长。要注意的是这个sender不仅保存了完整的类型,同时也会将相应的对象保存下来,一层一层进行了包装。就是这样就没有了所谓的类型擦除。

receiver构造

继续来看例子:

auto ret = sync_wait_r<int>(std::move(sender));

通过sync_wait_r来对sender开始进行任务的执行,使用sync_wait_r获取到最终执行完成的结果。看下sync_wait_r的实现细节。

namespace _sync_wait_r_cpo {
  template <typename Result>
  struct _fn {
    template(typename Sender)
      (requires sender<Sender>)
    decltype(auto) operator()(Sender&& sender) const {
      using Result2 = non_void_t<wrap_reference_t<decay_rvalue_t<Result>>>;
      return _sync_wait::_impl<Result2>((Sender&&) sender);
    }
  };
} // namespace _sync_wait_r_cpo

代码很简单,仅仅是直接去调用_sync_wait::_impl函数:

template <typename Result, typename Sender>
std::optional<Result> _impl(Sender&& sender) {
  using promise_t = _sync_wait::promise<Result>;
  promise_t promise;
  manual_event_loop ctx;

  // Store state for the operation on the stack.
  auto operation = connect(
      (Sender&&)sender,
      _sync_wait::receiver_t<Result>{promise, ctx});

  start(operation);

  ctx.run();

  switch (promise.state_) {
    case promise_t::state::done:
      return std::nullopt;
    case promise_t::state::value:
      return std::move(promise.value_).get();
    case promise_t::state::error:
      std::rethrow_exception(promise.exception_.get());
    default:
      std::terminate();
  }
}

impl函数通过connect函数对sender和receiver进行了绑定,返回了operation对象,对operation执行start,使用manual_event_loop对象的run函数对主线程进行阻塞直到所有的任务完成,最后会得到结果返回。那么先看关键的connect函数做了什么

首先要知道传递给connect的是_then::sender和临时构造了一个_sync_wait::receiver。

  template(typename Sender, typename Receiver)
  friend auto tag_invoke(tag_t<unifex::connect>, Sender&& s, Receiver&& r)
      -> connect_result_t<member_t<Sender, Predecessor>, receiver_t<remove_cvref_t<Receiver>>> {
    return unifex::connect(
      static_cast<Sender&&>(s).pred_,
      receiver_t<remove_cvref_t<Receiver>>{
        static_cast<Sender&&>(s).func_,
        static_cast<Receiver&&>(r)});
  }

调用connect时会调用到_then::sender的tag_invoke中,函数中还是会继续调用connect函数,不过这一次的sender参数就是_then::sender的pred_成员,也就是_via::sender,同时还会构造一个_then中receiver_t,我们称他为then::receiver,这里then::receiver会保存_then::sender的func和传递进来的Receiver,也就是_sync_wait::receiver,那我们看下函数内部connect的参数的类型是什么:connect(_via::sender<…>, _then::receiver<Func, _sync_wait::receiver>).

接下来就会_via::sender的connect函数:

  template <typename Receiver>
  auto connect(Receiver&& receiver) && {
    return unifex::connect(
        static_cast<Predecessor&&>(pred_),
        predecessor_receiver<Successor, Receiver>{
            static_cast<Successor&&>(succ_),
            static_cast<Receiver&&>(receiver)});
  }

像前边一样,_via::sender会取出来自己的pred作为现在connect的sender,自己的succ和传递进来的receiver包装成一个全新的receiver,也就是:
connect(_just::sender<Values…>, _via::receiver<_manual_event_loop::context::scheduler::schedule_task, _then::receiver<Func, _sync_wait::receiver>>)

接下来就会调用到_just::sender的tag_invoke函数中:

  template(typename This, typename Receiver)
  auto tag_invoke(tag_t<connect>, This&& that, Receiver&& r)
      -> operation<Receiver, Values...> {
    return {static_cast<This&&>(that).values_, static_cast<Receiver&&>(r)};
  }

这里会构造一个operation对象,使用自己的values和_via::receiver作为参数,那么这个operation就返回给最初的调用地方,同时receiver也构造完成。

然后我们回到_sync_wait::_impl函数:

std::optional<Result> _impl(Sender&& sender) {
  // ...

  // Store state for the operation on the stack.
  auto operation = connect(
      (Sender&&)sender,
      _sync_wait::receiver_t<Result>{promise, ctx});

  start(operation);

  // ...
}

我们就知道了这里的operation就是_just::sender构造的_just::operation, 接下来就调用start函数并传入调用。

任务的执行

然后我们发现会调用到_just::operation的start函数:

template <typename Receiver, typename... Values>
using operation = typename _op<remove_cvref_t<Receiver>, Values...>::type;

template <typename Receiver, typename... Values>
struct _op<Receiver, Values...>::type {
  std::tuple<Values...> values_;
  Receiver receiver_;

  void start() & noexcept {
    std::apply(
        [&](Values&&... values) {
        unifex::set_value((Receiver &&) receiver_, (Values &&) values...);
        },
        std::move(values_));
  }
};

可以看到start函数中就是使用apply函数针对receiver进行set_value。
接下来就会调用到_via::receiver的set_value函数

  template <typename... Values>
  void set_value(Values&&... values) && noexcept {
    submit(
        (Successor &&) successor_,
        value_receiver<Receiver, Values...>{
            {(Values &&) values...}, (Receiver &&) receiver_});
  }

set_value会调用summit函数,同时会将_via::receiver的成员successor_和receiver_作为参数传递,successor_是_manual_event_loop::context::scheduler::schedule_task, receiver_则是_then::receiver<Func, _sync_wait::receiver>,同样也会将receiver_成员和values包装成一个value_receiver,value_receiver就变成了value_receiver<Values, _then::receiver<Func, sync_wait::receiver>>,然后就去调用submit函数。
submit比较特殊不是直接去调用schedule_task的submit函数,而是会先构造一个submit::operation<schedule_task, value_receiver<…>>,然后调用start函数。

在submit::operation的构造函数中会调用submit::operatio的sender(schedule_task)的connect函数赋值给inner

template <typename Sender, typename Receiver>
class _op<Sender, Receiver>::type {
  template <typename Receiver2>
  explicit type(Sender&& sender, Receiver2&& receiver)
    : receiver_((Receiver2 &&) receiver)
    , inner_(unifex::connect((Sender &&) sender, wrapped_receiver{this}))
  {}

  void start() & noexcept {
    unifex::start(inner_);
  }
};

先来看connect会调用schedule_task的connect函数,我们称这里wrapped_receiver为submit_receiver,那么传给connect函数的receiver就是submit_receiver<value_receiver<Values, _then::receiver<Func, _sync_wait::receiver>>>

template <typename Receiver>
operation<Receiver> connect(Receiver&& receiver) const {
    return operation<Receiver>{(Receiver &&) receiver, loop_};
}

那么我们可以看到把调度器给从sender拆解下来了,并且剩余的部分又包装了一个receiver,其实也就是这里才算完整的receiver构建完成。

继续我们schedule_task的connect函数,会构造一个_manual_event_loop::operation对象,传入了loop_对象,也就是调度器context对象。

在然后就开始从submit的start函数调用到_manual_event_loop::operation的start函数:

template <typename Receiver>
inline void _op<Receiver>::type::start() noexcept {
  loop_->enqueue(this);
}

终于到这里了,把_manual_event_loop::operation放到了调度器任务队列了。因为我们在构造conetext那里就会执行run函数,run函数就是从任务队列中取任务运行。

void context::run() {
  std::unique_lock lock{mutex_};
  while (true) {
    while (head_ == nullptr) {
      if (stop_) return;
      cv_.wait(lock);
    }
    auto* task = head_;
    head_ = task->next_;
    if (head_ == nullptr) {
      tail_ = nullptr;
    }
    lock.unlock();
    task->execute();
    lock.lock();
  }
}

这里看到会执行_manual_event_loop::operation的execute,最终会调用到execute_impl函数:

void execute_impl(task_base* t) noexcept {
    auto& self = *static_cast<type*>(t);
    if constexpr (is_stop_never_possible_v<stop_token_type>) {
      unifex::set_value(std::move(self.receiver_));
    } else {
      // ...
    }
  }

然后大家也可以猜到了,就是调用receiver的set_value函数了,我们又要回到_submit::receiver的中了。

template(typename... Values)
void set_value(Values&&... values) && noexcept {
    auto allocator = get_allocator(get_receiver());
    unifex::set_value(std::move(get_receiver()), (Values &&) values...);
    destroy(std::move(allocator));
}

这里的Values就是没有值,然后会调用_submit::receiver的receiver_成员的set_value函数,也就是value_receiver<Values, _then::receiver<Func, _sync_wait::receiver>>的函数:

void set_value() noexcept {
    std::apply(
        [&](Values && ... values) noexcept {
          unifex::set_value(
              std::forward<Receiver>(receiver_), (Values &&) values...);
        },
        std::move(values_));
}

接着就调用_then::receiver的set_value函数:

template <typename... Values>
void set_value(Values&&... values) && noexcept {
    using result_type = std::invoke_result_t<Func, Values...>;
    unifex::set_value(
        (Receiver &&) receiver_,
        std::invoke((Func &&) func_, (Values &&) values...));
}

这里只留下了一些关键代码方便理解,首先调用func函数并将返回值传递给下一个receiver的set_value函数。
然后就是_sync_wait::receiver的set_value函数:

template <typename... Values>
void set_value(Values&&... values) && noexcept {
    unifex::activate_union_member(promise_.value_, (Values&&)values...);
    promise_.state_ = promise<T>::state::value;

    signal_complete();
}

void signal_complete() noexcept {
    ctx_.stop();
}

这里就是把then执行完的赋给promise_,并且更新完成的状态。这里的ctx就是最开始sync_wait_r函数中manual_event_loop对象,调用stop告知sync_wait_r函数中阻塞全部任务已完成可以结束程序了。

用一张图完整的来描述sender和receiver构建的整个过程:
在这里插入图片描述

总结

本篇文章通过阅读libunifex的源码,带着大家了解下c++的executor的任务构建流程及前后依赖的任务执行过程,尽管executor还尚未进入标准,libunifex已经算比较好的诠释了exeutor,当然除了我们上边讲解的简单的前后依赖的任务,还有并行的任务流,libunifex使用when_all来实现。除了单线程single_thread_context的context,libunifex还提供了线程池的context来供使用。

文中主要侧重了解大概的流程而忽略了一些实现细节,比如说cpo,tag_invoke,一些concept等等。只能大家自己去学习了,可以参考ref的链接

ref

https://zhuanlan.zhihu.com/p/395250667
https://mp.weixin.qq.com/s/oKCtKZq1R5PkVTSJxEhLfQ
https://blog.csdn.net/QcloudCommunity/article/details/125611481 系列
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p1341r0.pdf

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/419539.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

尚硅谷大数据技术Zookeeper教程-笔记03【源码解析-算法基础】

视频地址&#xff1a;【尚硅谷】大数据技术之Zookeeper 3.5.7版本教程_哔哩哔哩_bilibili 尚硅谷大数据技术Zookeeper教程-笔记01【Zookeeper(入门、本地安装、集群操作)】尚硅谷大数据技术Zookeeper教程-笔记02【服务器动态上下线监听案例、ZooKeeper分布式锁案例、企业面试真…

多模态大模型的发展、挑战与应用

多模态大模型的发展、挑战与应用 2023/04/15 研究进展 随着 AlexNet [1] 的出现&#xff0c;过去十年里深度学习得到了快速的发展&#xff0c;而卷积神经网络也从 AlexNet 逐步发展到了 VGG [2]、ResNet [3]、DenseNet [4]、HRNet [5] 等更深的网络结构。研究者们发现&#…

用vscode运行Java程序初体验

最近开始学习Java编程了&#xff0c;以前学习过C、C 、Python&#xff0c;主要用微软的visual studio code来运行python程序&#xff0c;于是就尝试了用vscode来运行java代码&#xff0c;记录一下使用的经验&#xff0c;帮助大家少走弯路。 安装了Java的集成编辑器IDE "Ec…

c++STL之关联式容器

目录 set容器 set的默认构造 set的插入与迭代器 set集合的元素排序 set集合的初始化及遍历 从小到大(默认情况下) 从大到小 仿函数 set的查找 pair的使用 multiset容器 map和multimap容器 map的插入与迭代器 map的大小 map的删除 map的查找 关联式容器&#…

【LeetCode: 337. 打家劫舍 III | 暴力递归=>记忆化搜索=>动态规划 | 树形dp】

&#x1f680; 算法题 &#x1f680; &#x1f332; 算法刷题专栏 | 面试必备算法 | 面试高频算法 &#x1f340; &#x1f332; 越难的东西,越要努力坚持&#xff0c;因为它具有很高的价值&#xff0c;算法就是这样✨ &#x1f332; 作者简介&#xff1a;硕风和炜&#xff0c;…

整数二分从入门到精通

前言&#xff1a; 开个玩笑&#xff0c;我们写算法可不能这样哈~ 好了&#xff0c;正片开始&#xff1a; 你是否曾经也有过整数二分因为一直死循环而苦恼&#xff0c;你是否因为搞不清楚整数二分的边界处理而焦躁&#xff0c;明明很简单的一道二分&#xff0c;但是最后就是搞…

Python入门教程+项目实战-9.1节: 字符串的定义与编码

目录 9.1.1 理解字符串 9.1.2 字符串的类型名 9.1.3 字符的数字编码 9.1.4 常用的字符编码 9.1.5 字符串的默认编码 9.1.6 字符串的编码与解码 9.1.7 转义字符详解 9.1.8 对字符串进行遍历 9.1.9 知识要点 9.1.10 系统学习python 9.1.1 理解字符串 理解字符串&#…

005:Mapbox GL添加全屏显示功能

第005个 点击查看专栏目录 本示例的目的是介绍演示如何在vue+mapbox中添加全屏显示功能 。 直接复制下面的 vue+mapbox源代码,操作2分钟即可运行实现效果 文章目录 示例效果配置方式示例源代码(共60行)相关API参考:专栏目标示例效果 配置方式 1)查看基础设置:https://…

还在因为写项目函数太多而烦恼?C++模板一文带你解决难题

&#x1f4d6;作者介绍&#xff1a;22级树莓人&#xff08;计算机专业&#xff09;&#xff0c;热爱编程&#xff1c;目前在c&#xff0b;&#xff0b;阶段>——目标Windows&#xff0c;MySQL&#xff0c;Qt&#xff0c;数据结构与算法&#xff0c;Linux&#xff0c;多线程&…

轮廓查找与绘制

轮廓查找与绘制 1)什么是轮廓 轮廓可以简单认为成将连续的点&#xff08;连着边界&#xff09;连在一起的曲线&#xff0c;具有相同的颜色或者灰度&#xff0c;提取轮廓就是提取 这些具有相同颜色或者灰度的曲线&#xff0c;或者说是连通域&#xff0c;轮廓在形状分析和物体…

学习系统编程No.20【进程间通信之命名管道】

引言&#xff1a; 北京时间&#xff1a;2023/4/15/10:34&#xff0c;今天起床时间9:25&#xff0c;睡了快8小时&#xff0c;昨天刷视屏刷了一个小时&#xff0c;本来12点的时候发完博客洗把脸就要睡了&#xff0c;可惜&#xff0c;看到了一个标题&#xff0c;说实话&#xff0…

.Net路由操作!!!!

什么是路由 问题 答案 路由是什么&#xff1f; 路由系统负责处理传入的请求并选择控制器和操作方法来处理它们。 路由系统还用于在视图中生成路由&#xff0c;称为传出的URL 路由有什么用&#xff1f; 路由系统能够灵活地处理请求&#xff0c;面不是将URL与Visual Studio…

MySQL(31)-ubuntu20.04-下安装mysql5.7

ubuntu20.04 下apt 默认安装的是8.0版本&#xff0c;如果要安装5.7版有如下3种方式&#xff1a; 1 下载 MySQL 二进制压缩包&#xff0c;解压并设置相关的参数即可运行 2 通过命令 apt install 进行安装&#xff0c;先下载 MySQL 5.7 对应的源&#xff0c;然后执行安装命令 ap…

5 分钟带你小程序入门 [实战总结分享]

微信小程序常常用 4 种文件类型 JS 文件 JS 在小程序中用于编写页面逻辑和交互效果&#xff0c;可调用 API 接口完成数据请求和处理&#xff0c;也可以使用第三方库和框架。 模块化编程&#xff1a;小程序中JS文件可以使用ES6的模块化语法&#xff0c;通过export和import来…

【vue3】关于watch与computed的用法看这个就ok

&#x1f609;博主&#xff1a;初映CY的前说(前端领域) ,&#x1f4d2;本文核心&#xff1a;watch()与computed的使用【vue2中watch|computed概念详解】&#xff0c;本文将介绍在vue3中怎么使用这两者技能 【前言】vue2当中有这两个技能&#xff0c;那么vue3中的watch与compute…

【云原生进阶之容器】第六章容器网络6.4.1--Flannel组网方案综述

《云原生进阶之容器》专题索引: 第一章Docker核心技术1.1节——Docker综述

【Unity】用HDRI作为Unity的Skybox

教程&#xff1a;用HDRI作为Unity的Skybox 在Unity中&#xff0c;Skybox是用于创建环境背景的一种组件。使用高动态范围图像&#xff08;HDRI&#xff09;作为Skybox可以提供更真实的环境背景。以下是使用HDRI作为Unity Skybox的步骤&#xff1a; 步骤1&#xff1a;下载HDRI图…

进销存管理系统能为企业带来哪些实际效益?

随着互联网的不断发展&#xff0c;如今的商业世界已经越来越向数字化转型。拥有一套完整的数字化的进销存管理能够极大地提升公司货物进出库存情况的效率和准确性&#xff0c;避免过程中出现不必要的错误和漏洞&#xff0c;从而帮助企业更加稳健地自我发展。那么&#xff0c;一…

华为MatePad有什么好用的软件?

现如今伴随着办公方式的转变&#xff0c;人们正迫切地寻找能够顺应时代的“生产力新工具”&#xff0c;它既要能够满足线上/线下灵活切换&#xff0c;又要具备绘画、键入、远程沟通、跨终端联动等多种功能。 对大多数人来说&#xff0c;日常使用华为平板只是满足一下娱乐和生活…

【SSA-LSTM】基于麻雀算法优化LSTM 模型预测研究(Matlab代码实现)

&#x1f4a5;&#x1f4a5;&#x1f49e;&#x1f49e;欢迎来到本博客❤️❤️&#x1f4a5;&#x1f4a5; &#x1f3c6;博主优势&#xff1a;&#x1f31e;&#x1f31e;&#x1f31e;博客内容尽量做到思维缜密&#xff0c;逻辑清晰&#xff0c;为了方便读者。 ⛳️座右铭&a…