第2章 线程管理
2.1 线程的基本操作
每个程序至少有一个执行main()函数的线程, 其他线程与主线程同时运行。 如main()函数执行完成退出一样, 线程执行完函数也会退出。 为线程创建std::thread() 对象后, 需要等待该线程结束。 那么, 先启动线程。
2.1.1 启动线程
一些情况下, 任务函数对象需要通过某种通讯机制进行参数的传递, 或者执行一系列独立操作, 通过通讯机制传递信号让线程信号停止。 先放下这些特殊情况不谈, 简单来说, 使用C++线程库启动线程, 就是构造std::thread对象:
void do_some_work();
std::thread my_thread(do_some_work);
这里需要包含头文件, std::thread可以通过有函数操作符类型的实例进行构造:
class background_task
{
public:void operator()() const{do_something();do_something_else();}
};background_task f;
std::thread my_thread(f);
代码中, 提供的函数对象会复制到新线程的存储空间中, 函数对象的执行和调用都在线程的内存空间中进行。
注意:当把函数对象传入线程构造函数中时,如果你传递了一个临时变量, 而不是一个命名的变量。 C++编译器会将其解析为函数声明, 而不是雷翔对象的定义。
std::thread my_thread(background_task());
这相当对声明了一个名为my_thread的函数, 这个函数带有一个函数(函数指针执行没有参数并返回background_task对象的函数), 返回一个std::thread对象的函数。
使用在前面命名函数对象的方式, 或使用多组括号(1), 或者使用统一的初始化语法(2), 都可以避免该问题。
std::thread my_thread((background_task())); //(1)
std::thread my_thread{background_task()}; //(2)
Lambda表达式也能避免这个问题。 Lambda表达式允许使用一个可以捕获局部变量的局部函数(可以避免传递参数)。 之前的例子可以改写为Lambda表达式的方式:
std::thread my_thread([]{do_something();do_something_else();});
线程启动后是要等待线程结束, 还是让其自动运行。 当std::thread()对象销毁之前还没有做出决定, 程序就会终止(std::thread的析构函数会调用std::terminate())。因此, 即使是有异常存在, 也需要确保线程能够正确汇入(joined)或分离(detached)。
如果不等待线程汇入, 就必须保证线程结束之前, 访问数据的有效性。这不是一个新问题–单线程代码中, 对象销毁之后再去访问, 会产生未定义行为–不过, 线程的生命周期增加了这个问题发生的几率。
这种情况很可能发生在线程还没借宿, 函数已经退出的时候, 这时线程函数还持有函数局部变量的指针或引用。
代码2.1 函数已经返回, 线程依旧访问局部变量
struct func
{int& i;func(int& i_) : i(i_) {}void operator() (){for (unsigned j=0 ; j<1000000 ; ++j){do_something(i); // (1) 潜在访问隐患:空引用(dangling reference)}}
};void oops()
{int some_local_state=0;func my_func(some_local_state);std::thread my_thread(my_func);my_thread.detach(); // (2) 不等待线程结束
} // (3) 新线程可能还在运行
代码中, 已经决定不等待线程(使用了detach()(2)), 所以当oops()函数执行完成时(3), 线程中的函数可能还在运行。 如果线程还在运行, 就会去调用do_something(i)(1), 这时就会访问已经销毁的变量。 如同一个单线程程序–允许在函数完成后继续拥有局部变量的指针或引用。 当然, 这种情况发生时, 错误并不明显, 会使多线程更容易出错。 运行顺序参考下表2.1。
表2.1 分离( leaving it
to run)线程在局部变量销毁后, 仍对该变量进行访问。
主线程 | 新线程 |
---|---|
使用some_local_state构造my_func | |
开启新线程my_thread | |
启动 | |
调用func::operator() | |
将my_thread分离 | 执行func::operator();可能会在do_something中调用some_local_state的引用 |
销毁some_local_state | 持续运行 |
退出oops函数 | 持续执行func::operator();可能会在do_something中调用some_local_state的引用 --> 导致未定义行为 |
这种情况的常规处理方法:将数据复制到线程中。 如果使用一个可调用的对象作为线程函数, 这个对象就会复制到线程中, 而后原始对象会立即销毁。 如代码2.1所示, 但对于对象中包含的指针和引用还需谨慎。 使用访问局部变量的函数去创建线程是一个糟糕的注意(bad idea)。 | |
此外, 可以通过join()函数来确保线程在主函数完成前结束。 |
2.1.2 等待线程完成
如需等待线程, 需要使用join()。 将代码2.1的my_thread.detach()替换为my_thread.join(), 就可以确保局部变量在线程完成后才销毁。 因为主线程并没有做什么事, 使用独立的线程去执行函数变得意义不大。 但在实际中, 原始线程要么有自己的工作要做, 要么会启动多个子线程来做一些有用的工作, 并等待这些线程结束。
当你需要对等待中的线程有更灵活的控制时, 如: 看一下某个线程是否结束, 或者只等待一段时间(超过时间就判定为超时)。 想要做到这些, 需要使用其他机制来完成, 比如条件变量和future。 调用join(), 还可以清理了线程相关的内存, 这项std::thread对象将不再与已完成的线程有任何关联。 这意味着, 只能对一个线程使用一次join(), 一旦使用过join(), std::thread对象就不能再次汇入了。 当对使用joinable()时, 将返回flase。
2.1.3 特殊情况下的等待
如果想要分离线程, 可以在线程启动后, 直接使用detach()进行分离。 如果等待线程, 则需要细心挑选使用join()的位置。 当在线程运行后产生的一场, 会在join()调用之前抛出, 这样就会跳过join()。
避免应用被抛出的异常所终止。 通常, 在无异常的情况下使用join()时, 需要在异常处理过程中调用join(), 从而避免生命周期的问题。
代码2.2 等待线程完成
struct func; // 定义在代码2.1中
void f()
{int some_local_state=0;func my_func(some_local_state);std::thread t(my_func);try{do_something_in_current_thread();}catch(...){t.join(); // (1)throw;}t.join(); // (2)
}
代码2.2中使用了try/catch块确保线程退出后函数才结束。 当函数正常退出后, 会执行(2)处。 当执行过程中抛出异常, 程序会执行到(1)。 如果线程在函数之前结束–就要查看是否因为线程函数使用了局部变量的引用–而后确定一下程序可能会退出的途径。有一个叫RAII(Resource Acquisition Is Initialization)的机制可以解决这个问题,提供一个类, 在析构函数中使用join()。
代码2.3 使用RAII等待线程完成
class thread_guard
{std::thread& t;
public:explicit thread_guard(std::thread& t_):t(t_){}~thread_guard(){if(t.joinable()) // (1){t.join(); // (2)}}thread_guard(thread_guard const&)=delete; // (3)thread_guard& operator=(thread_guard const&)=delete;
};struct func; // 定义在代码2.1中void f()
{int some_local_state=0;func my_func(some_local_state);std::thread t(my_func);thread_guard g(t);do_something_in_current_thread();
} // (4)
线程执行到(4)时,局部对象就要被逆序销毁了。 因此, thread_guard对象g是第一个被销毁的, 这时线程在析构函数中被加入(2)到原始线程中。 即使do_something_in_current_thread抛出一个异常, 这个销毁依然会发生。
在thread_guard析构函数的测试中, 首先判断线程是否可汇入(1)。如果可汇入, 会调用join()(2)进行汇入。拷贝构造函数和拷贝复制操作标记为=delete(3), 是为了不让编译器自动生成。 直接对对象进行拷贝或复制是很危险的, 因为这可能会弄对已汇入的线程。 通过删除生命, 任何尝试给thread_guard对象复制的操作都会引发一个编译错误。 参阅附录A的A.2节。
如果不想等待线程结束, 可以分离线程, 从而避免异常。 不过, 这就打破了线程与std::thread对象的联系, 即使线程仍然在后台运行着, 分离操作也能确保在std::thread对象销毁时不调用std::terminate()。
2.1.4 后台运行线程
使用detach()会让线程在后台运行, 这就意味着与主线程不能直接交互。 如果线程分离, 就不可能有std::thread对象使用它, 分离线程的确在后台运行, 所以分离的线程不能汇入。 不过C++运行库保证, 当线程退出时, 相关资源的能够正确回收。
分离线程通常为守护线程(daemon threads)。 UNIX中守护线程,是指没有任何显示的接口, 并在后台运行的线程, 这种线程的特点就是长时间运行。线程的生命周期可能会从应用的起始到结束, 可能会在后台监视文件系统, 还有可能对缓存进行清理, 或者对数据结构进行优化。另外, 分离线程只能确定线程什么时候结束, 发后即忘(fire and forget)的任务使用到就是分离线程。
如2.1.2节所示, 调用std::thread成员函数detach成员函数detach()来分离一个线程。 之后, 相应的std::thread对象与实际执行的线程无关, 并且这个线程也无法汇入:
std::thread t(do_background_work);
t.detach();
assert(!t.joinable());
为了从std::thread对象中分离线程, 不能对没有执行线程的std::thread对象使用detach(), 并且要使用同样的方式进行检查–当std::thread对象使用t.joinable()返回的是true, 就可以使用t.detach()。
试想如何能让一个文字处理应用同事编辑多个文档。 无论是用户界面, 还是在内部应用内部进行, 都有很多的解决方法。 虽然, 这些窗口看起来是完全独立的, 每个窗口都有自己独立的菜单选项, 但他们却运行在同一个应用实例中。 一种内部处理方式是, 让每个文档处理窗口拥有自己的线程。 每个线程运行同样的代码, 并个隔离不同窗口处理的数据。 如此这般, 打开一个文档就要启动一个新线程。 因为是对独立文档进行操作, 所以没有必要等待其他线程完成, 这里就可以让文档处理窗口运行在分离线程上。
代码2.4使用分离线程处理文档
void edit_document(std::string const& filename)
{open_document_and_display_gui(filename);while(!done_editing()){user_command cmd=get_user_input();if(cmd.type==open_new_document){std::string const new_name=get_filename_from_user();std::thread t(edit_document,new_name); // (1)t.detach(); // (2)}else{process_user_input(cmd);}}
}
如果用户选择打开一个新文档, 需要启动一个新线程去打开新文档(1), 并分离线程(2)。 与当前线程做出的操作一样, 新线程只不过是打开另一个文件而已。 所以, edit_document函数可以复用, 并通过传参的形式打开新的文件。
这个例子也展示了传参启动线程的方法: 不仅可以向std::thread构造函数(1)传递函数名, 还可以传递函数所需的参数(实参)。 当然, 也有其他方法可以完成这项功能, 比如: 使用带有数据的成员函数, 代替需要传参的普通函数。
2.2 传递参数
如代码2.4所示, 向可调用对象或函数传递参数很简单, 只需要将这些参数作为std::thread构造函数的附加函数即可。这些参数会拷贝至新线程的内存空间中(同临时变量一样)。即使函数中的参数是引用的形式,拷贝操作也会执行。
void f(int i, std::string const& s);
std::thread t(f, 3, "hello");
代码创建了一个调用(3, “hello”)的线程。 注意, 函数f需要一个std::string对象作为第二个参数, 但这里使用的是字符串的字面值, 也就是char const*类型, 线程的上下文完成字面值想std::string的转化。 需要特别注意, 指向动态变量的指针作为参数的情况, 代码如下:
void f(int i,std::string const& s);
void oops(int some_param)
{char buffer[1024]; //(1)sprintf(buffer, "%i",some_param);std::thread t(f,3,buffer); // (2)t.detach();
}
buffer(1)是一个指针变量, 指向局部变量, 然后此局部变量通过buffer传递到新线程中(2)。此时, 函数oops可能会在buffer转换成std::string之前结束, 从而导致未定义的行为。 因为, 无法保证隐式转换的操作和std::thread构造函数的拷贝操作的顺序, 有可能std::thread的构造函数拷贝的是转换前的变量(buffer指针)。解决方案就是在传递到std::thread构造函数之前, 就将字面值转化为std::string:
void f(int i,std::string const& s);
void not_oops(int some_param)
{char buffer[1024];sprintf(buffer,"%i",some_param);std::thread t(f,3,std::string(buffer)); // 使用std::string,避免悬空指针t.detach();
}
相反的情形(期望传递一个非常量引用, 但复制了整个对象)倒是不会出现, 因为会出现编译错误。 比如, 尝试使用线程更新引用传递的数据结构:
void update_data_for_widget(widget_id w, widget_data& data); // (1)
void oops_again(widget_id w)
{widget_data data;std::thread t(updata_data_for_widget, w, data); // (2)display_status();t.join();process_widget_data(data);
}
虽然updata_data_for_widget(1)的第二个参数期待传入一个引用, 但std::thread的构造函数(2)并不知晓, 构造函数无视函数参数类型, 盲目地拷贝已提供的变量。 不过, 内部代码会将拷贝的参数以右值的方式进行传递, 这是为了哪些支持移动的类型, 而后会尝试以右值为实参调用updata_data_for_widget。 但因为函数期望的是一个非常量引用作为参数(而非右值), 所以会在编译时出错。 对于熟悉std::bing的开发者来说, 问题的解决方法很简单:可以使用std::ref将参数转换成引用的形式。 因此可将线程的调用改为以下形式:
std::thread t(update_data_for_widget, w, std::ref(data));
这样update_data_for_widget就会收到data的引用, 而非data的拷贝副本, 这样代码就能顺利的通过编译了。
如果熟悉std::bind, 就应该不会对以上传参的语法感到陌生, 因为std::thread构造函数和std::bind的操作在标准库中以相同的机制进行定义。 比如, 你也可以传递一个成员函数指针作为线程函数, 并提供一个合适的对象指针作为第一个参数:
class X
{public:void do_lengthy_work();
};
X my_x;
std::thread t(&X::do_lengthy_work, &my_x); // 1
另一种有趣的情形是, 提供的参数仅支持移动(move),不能拷贝。 "移动"是指原始对象中的数据所有权转移给另一个对象, 从而这些数据就不再原始对象中保存(类似于剪切)。 std::unique_ptr就是这样一种类型, 这种类型为动态分配的对象提供内存自动管理机制(类似垃圾回收机制)。 同一时间内, 只允许一个std::unique_ptr实例指向一个对象, 并且当这个实例销毁时, 指向的对象也将被删除。 移动构造函数(move constructor)和移动赋值操作符(move assignment operator)允许一个对象的所有权后, 就会留下一个空指针。 使用移动操作可以将对象转换成函数可接受的实参类型, 或满足函数返回值类型要求。 当原对象是临时变量时, 则自动进行移动操作, 但当原对象是一个命名变量, 移动的时候就需啊哟使用std::move()进行显示移动。下面的代码展示了std::move的用法, 展示了std::move 是如何移动态对象的所有权到线程中去的:
void process_big_object(std::unique_ptr<big_object>);
std::unique_ptr<big_object> p(new big_object);
p->prepare_data(42);
std::thread t(jprocess_big_object, std::move(p));
通过在std::thread构造函数中执行std::move§, big_object对象的所有权首先被转移到新创建线程的内部存储中, 之后再传递给process_big_object函数。
C++标准线程库中和std::unique_ptr在所属权上相似的类有好几种, std::thread为其中之一。 虽然, std::thread不像std::unique_ptr能占有动态对象的所有权, 但是它能占用其他资源: 每个实例都负责管理一个线程。 线程的所有权可以在多个std::thread实例中转移, 这依赖于std::thread实例的可移动且不可复制性。 不可复制性表示某一时间点, 一个std::thread实例只能关联一个执行线程。 可移动性是的开发者可以自己决定, 哪个实例拥有线程实际执行的所有权。
相关文章:

第2章 线程管理
2.1 线程的基本操作 每个程序至少有一个执行main()函数的线程, 其他线程与主线程同时运行。 如main()函数执行完成退出一样, 线程执行完函数也会退出。 为线程创建std::thread() 对象后, 需要等待该线程结束。 那么, 先启动线程。…...

机器学习第二十六周周报 ARIMA Clustering model
文章目录 week26 ARIMA & Clustering model摘要Abstract一、龙格库塔方法二、文献阅读1. 题目2. abstract3. 网络架构3.1 ARIMA模型3.2 K-means聚类3.3 评估标准 4. 文献解读4.1 Introduction4.2 创新点4.3 实验过程4.3.1 数据来源4.3.2 使用ARIMA模型预测4.3.3 K-means聚类…...

支持XP系统的最新firefox浏览器
都2024年了,还XP系统?事情是这样的,我要维护一下非常老的项目,系统部署在windows server 2003下面。升级系统是不太可能了。只能在老系统上维护,老的系统上自带的IE 6.0浏览器,在当前几乎是不可用状态&…...

Pytorch从零开始实战17
Pytorch从零开始实战——生成对抗网络入门 本系列来源于365天深度学习训练营 原作者K同学 文章目录 Pytorch从零开始实战——生成对抗网络入门环境准备模型定义开始训练总结 环境准备 本文基于Jupyter notebook,使用Python3.8,Pytorch1.8cpu…...

openssl3.2 - 官方demo学习 - signature - EVP_DSA_Signature_demo.c
文章目录 openssl3.2 - 官方demo学习 - signature - EVP_DSA_Signature_demo.c概述笔记END openssl3.2 - 官方demo学习 - signature - EVP_DSA_Signature_demo.c 概述 DSA签名(摘要算法SHA256), DSA验签(摘要算法SHA256) 签名 : 用发送者的私钥进行签名. 验签 : 用发送者的公…...

vue2使用 element表格展开功能渲染子表格
默认样式 修改后 样式2 <el-table :data"needDataFollow" border style"width: 100%"><el-table-column align"center" label"序号" type"index" width"80" /><el-table-column align"cent…...

一个简单的ETCD GUI工具
使用ETCD没有好用的GUI工具,随手用c#写了一个, 做得好玩的一个ETCD GUI工具,后面加上CLI 工具,类似于 redis Cli工具一样,简化在 Linux下面的操作,不知道有没有必要, git 地址如下,…...

vue2 使用pdf.js 实现pdf预览,并可复制文本
需求:pdf预览,并且可以选中pdf的内容进行复制。 在ruoyi的vue前端项目中用到,参考了网上不少文章,因为大部分没给具体的pdf.js版本,导致运行过程中报各种api 错误,经过尝试以下版本可用,…...

REPLACE INTO
简介 在数据库中,REPLACE INTO 是一种用于插入或更新数据的(DML) SQL 语句。它与 INSERT INTO 语句类似,但具有一些特殊的行为。 语法 REPLACE INTO table_name (column1, column2, ...) VALUES (value1, value2, ...); repla…...

idea 安装免费Ai工具 codeium
目录 概述 ide安装 使用 chat问答 自动写代码 除此外小功能 概述 这已经是我目前用的最好免费的Ai工具了,当然你要是有钱最好还是用点花钱的,比如copilot,他可以在idea全家桶包括vs,还有c/c的vs上运行,还贼强&am…...

关于C#中的Select与SelectMany方法
Select 将序列中的每个元素投影到新表单。 实例1 IEnumerable<int> squares Enumerable.Range(1, 10).Select(x > x * x);foreach (int num in squares) {Console.WriteLine(num); } /*This code produces the following output:149162536496481100 */ 实例2 str…...

CentOS上安装Mellanox OFED
打开Mellanox官网下载驱动 Linux InfiniBand Drivers 点击下载链接跳转至 Tgz解压缩执行 ./mlnxofedinstall发现缺少模块 # ./mlnxofedinstall Logs dir: /tmp/MLNX_OFED_LINUX.11337.logs General log file: /tmp/MLNX_OFED_LINUX.11337.logs/general.log Verifying KMP rpm…...

无/自监督去噪(1)——一个变迁:N2N→N2V→HQ-SSL
目录 1. 前沿2. N2N3. N2V——盲点网络(BSNs,Blind Spot Networks)开创者3.1. N2V实际是如何训练的? 4. HQ-SSL——认为N2V效率不够高4.1. HQ-SSL的理论架构4.1.1. 对卷积的改进4.1.2. 对下采样的改进4.1.3. 比N2V好在哪ÿ…...

【24.1.19】
24.1.19 本周工作内容下周工作计划 本周工作内容 本周的话主要的一个工作还是第三部分页面部分的完成工作,那就先来汇报一下第三部分的工作进度,第三部分的页面工作呢已经完成啦,就在刚刚提交啦全部的代码,那么这一部分的工作呢也…...

使用mamba替换conda和anaconda配置环境安装软件
使用mamba替换miniconda和anaconda,原因是速度更快,无论是创建新环境还是激活环境 conda、mamba、anaconda都是蟒蛇的意思… 下载mambaforge wget https://github.com/conda-forge/miniforge/releases/latest/download/Mambaforge-Linux-x86_64.sh ba…...

鸿蒙开发系列教程(四)--ArkTS语言:基础知识
1、ArkTS语言介绍 ArkTS是HarmonyOS应用开发语言。它在保持TypeScript(简称TS)基本语法风格的基础上,对TS的动态类型特性施加更严格的约束,引入静态类型。同时,提供了声明式UI、状态管理等相应的能力,让开…...
Pix2Pix理论与实战
本文为🔗365天深度学习训练营 中的学习记录博客 原作者:K同学啊|接辅导、项目定制 我的环境: 1.语言:python3.7 2.编译器:pycharm 3.深度学习框架Pytorch 1.8.0cu111 一、引入 在之前的学习中,我们知道…...

[GN] 后端接口已经写好 初次布局前端需要的操作(例)
提示:前端项目一定要先引入组件 配置。再编码!!!! 文章目录 使用 vue-cli 脚手架初始化前端工程化配置引入Vue前端组件库 -- arco前后端联调引入Md 编辑器组件 使用 vue-cli 脚手架初始化 使用安装脚手架工具…...

AIGC:人工智能驱动的数据分析新时代
AIGC:人工智能驱动的数据分析新时代 随着人工智能技术的迅猛发展,我们正迎来数据分析的新时代,其中AIGC(Artificial Intelligence with Generative Capabilities)的应用成为引领潮流的重要方向。本文将深入探讨几个关…...

Windows Qt C++ VTK 借助msys环境搭建
本示例仅仅是搭建环境,后续使用还得大佬指导。 Qt 6.6.0 MinGW 64bit 借助msys2 来安装VTK 包,把*.dll 链接进来,就可以用了。 先安装VTK 包。 Package: mingw-w64-x86_64-vtk - MSYS2 Packages 执行 pacman 命令:pacman -…...

尚硅谷Nginx高级配置笔记
写在前面:本笔记是学习尚硅谷nginx可成的时候的笔记,不是原创,如有需要,可以去官网看视频,以下是pdf文件 Nginx高级 第一部分:扩容 通过扩容提升整体吞吐量 1.单机垂直扩容:硬件资源增加 云…...

论rtp协议的重要性
rtp ps流工具 rtp 协议,实时传输协议,为什么这么重要,可以这么说,几乎所有的标准协议都是国外创造的,感叹一下,例如rtsp协议,sip协议,webrtc,都是以rtp协议为基础&#…...

【Github搭建网站】零基础零成本搭建个人Web网站~
Github网站:https://github.com/ 这是我个人搭建的网站:https://xf2001.github.io/xf/ 大家可以搭建完后发评论区看看!!! 搭建教程:https://www.bilibili.com/video/BV1xc41147Vb/?spm_id_from333.999.0.0…...

unocss+iconify技术在vue项目中使用20000+的图标
安装依赖 npm i unocss iconify/json配置依赖 vue.config.js文件 uno.config.js文件 main.js文件 使用 <i class"i-fa:user"></i> <i class"i-fa:key"></i>class名是 i- 开头,跟库名:图标名,那都有什么库…...

python 自动化模块 - pyautogui初探
python 自动化模块 - pyautogui 引言一、安装测试二、简单使用三、常用函数总结 引言 在画图软件中使用pyautogui拖动鼠标,画一个螺旋式的正方形 - (源码在下面) PyAutoGUI允许Python脚本控制鼠标和键盘,以自动化与其他应用程序的交互。API的设计非常简…...

UE5 蓝图编辑美化学习
虚幻引擎中干净整洁蓝图的15个提示_哔哩哔哩_bilibili 1.双击线段成节点。 好用,爱用 2.用序列节点 好用,爱用 3.用枚举。 好用,能避免一些的拼写错误 4.对齐节点 两点一水平线 5.节点上下贴节点 (以前不懂,现在经常…...

基于动态顺序表实现通讯录项目
本文中,我们将使用顺序表的结构来完成通讯录的实现。 我们都知道,顺序表实际上就是一个数组。而使用顺序表来实现通讯录,其内核是将顺序表中存放的数据类型改为结构体,将联系人的信息存放到结构体中,通过对顺序表的操…...

python使用jupyter记笔记
目录 一、安装 二、运行jupyter 三、使用 四、记笔记 Jupyter Notebook(此前被称为 IPython notebook)是一个交互式笔记本,支持运行 40 多种编程语言。 Jupyter Notebook 的本质是一个 Web 应用程序,便于创建和共享程序文档&a…...

C#封装服务
C#封装服务 新建服务项目;重构 OnStart 和 OnStop using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Diagnostics; using System.Linq; using System.ServiceProcess; using System.Text; using S…...

手写Vue3源码
Vue3核心源码 B站视频地址:https://www.bilibili.com/video/BV1nW4y147Pd?p2&vd_source36bacfbaa95ea7a433650dab3f7fa0ae Monorepo介绍 Monorepo 是管理项目代码的一种方式,只在一个仓库中管理多个模块/包 一个仓库可以维护多个模块,…...