目录
4.4.6 运用std::experimental::when_any()函数等待多个future,直到其中之一准备就绪
4.4.8 基本的线程闩类std::experimental::latch
4.4.9 基本的线程卡类std::experimental::barrier
4.4.10 std::experimental::flex_barrier——std::experimental::barrier的灵活版本
上一章中,我们了解了线程间保护共享数据的方法。当然,我们不仅想要保护数据,还想对单独的线程进行同步。通常,线程会等待特定事件发生,或者等待某一条件达成。这可能需要定期检查“任务完成”标识,或将类似的东西放到共享数据中。像这种情况就需要在线程中进行同步,C++标准库提供了一些工具可用于同步,形式上表现为条件变量(condition variables)和future。并发技术规范中,为future添加了非常多的操作,并可与新工具锁存器(latches)(轻量级锁资源)和栅栏(barriers)一起使用。
从概念上来说,条件变量会与多个事件或其他条件相关,并且一个或多个线程会等待条件的达成。当某些线程被终止时,为了唤醒等待线程(允许等待线程继续执行),终止线程将会向等待着的线程广播“条件达成”的信息。
C++标准库对条件变量有两套实现:std::condition_variable和std::condition_variable_any,这两个实现都包含在头文件的声明中。两者都需要与互斥量一起才能工作(互斥量是为了同步),前者仅能与std::mutex一起工作,而后者可以和合适的互斥量一起工作,从而加上了_any的后缀。
- std::mutex mut;
- std::queue
data_queue; // 1 - std::condition_variable data_cond;
-
- void data_preparation_thread()
- {
- while(more_data_to_prepare())
- {
- data_chunk const data=prepare_data();
- std::lock_guard
lk(mut) ; - data_queue.push(data); // 2
- data_cond.notify_one(); // 3
- }
- }
-
- void data_processing_thread()
- {
- while(true)
- {
- std::unique_lock
lk(mut) ; // 4 - data_cond.wait(
- lk,[]{return !data_queue.empty();}); // 5
- data_chunk data=data_queue.front();
- data_queue.pop();
- lk.unlock(); // 6
- process(data);
- if(is_last_chunk(data))
- break;
- }
- }
wait()会去检查这些条件(通过Lambda函数),当条件满足(Lambda函数返回true)时返回。如果条件不满足(Lambda函数返回false),wait()将解锁互斥量,并且将线程(处理数据的线程)置于阻塞或等待状态。当准备数据的线程调用notify_one()通知条件变量时,处理数据的线程从睡眠中苏醒,重新获取互斥锁,并且再次进行条件检查。在条件满足的情况下,从wait()返回并继续持有锁。当条件不满足时,线程将对互斥量解锁,并重新等待。这就是为什么用std::unique_lock而不使用std::lock_guard的原因——等待中的线程必须在等待期间解锁互斥量,并对互斥量再次上锁,而std::lock_guard没有这么灵活。如果互斥量在线程休眠期间保持锁住状态,准备数据的线程将无法锁住互斥量,也无法添加数据到队列中。同样,等待线程也永远不会知道条件何时满足。
使用队列在多个线程中转移数据(如代码4.1)很常见。做得好的话,同步操作可以在队列内部完成,这样同步问题和条件竞争出现的概率也会降低。
代码4.5 使用条件变量的线程安全队列(完整版):
- #include
- #include
- #include
- #include
-
- template<typename T>
- class threadsafe_queue
- {
- private:
- mutable std::mutex mut; // 1 互斥量必须是可变的
- std::queue
data_queue; - std::condition_variable data_cond;
- public:
- threadsafe_queue()
- {}
- threadsafe_queue(threadsafe_queue const& other)
- {
- std::lock_guard
lk(other.mut) ; - data_queue=other.data_queue;
- }
-
- void push(T new_value)
- {
- std::lock_guard
lk(mut) ; - data_queue.push(new_value);
- data_cond.notify_one();
- }
-
- void wait_and_pop(T& value)
- {
- std::unique_lock
lk(mut) ; - data_cond.wait(lk,[this]{return !data_queue.empty();});
- value=data_queue.front();
- data_queue.pop();
- }
-
- std::shared_ptr
wait_and_pop() - {
- std::unique_lock
lk(mut) ; - data_cond.wait(lk,[this]{return !data_queue.empty();});
- std::shared_ptr
res(std::make_shared(data_queue.front())) ; - data_queue.pop();
- return res;
- }
-
- bool try_pop(T& value)
- {
- std::lock_guard
lk(mut) ; - if(data_queue.empty())
- return false;
- value=data_queue.front();
- data_queue.pop();
- return true;
- }
-
- std::shared_ptr
try_pop() - {
- std::lock_guard
lk(mut) ; - if(data_queue.empty())
- return std::shared_ptr
(); - std::shared_ptr
res(std::make_shared(data_queue.front())) ; - data_queue.pop();
- return res;
- }
-
- bool empty() const
- {
- std::lock_guard
lk(mut) ; - return data_queue.empty();
- }
- };
条件变量在多个线程等待同一个事件时也很有用。当线程用来分解工作负载,并且只有一个线程可以对通知做出反应时,与代码4.1中结构完全相同。当数据准备完成时,调用notify_one()将会唤醒一个正在wait()的线程,检查条件和wait()函数的返回状态(因为仅是向data_queue添加了一个数据项)。 这里不保证线程一定会被通知到,即使只有一个等待线程收到通知,其他处理线程也有可能因为在处理数据,而忽略了这个通知。
当条件为true时,等待线程只等待一次,就不会再等待条件变量了,所以尤其是在等待一组可用的数据块时,一个条件变量并非同步操作最好的选择。
接下来就来了解一下future,对于条件变量的补足。
C++标准库将这种事件称为future。当线程需要等待特定事件时,某种程度上来说就需要知道期望的结果。之后,线程会周期性(较短的周期)的等待或检查事件是否触发(检查信息板),检查期间也会执行其他任务(品尝昂贵的咖啡)。另外,等待任务期间也可以先执行另外的任务,直到对应的任务触发,而后等待future的状态会变为就绪状态。future可能是和数据相关(比如,登机口编号),也可能不是。当事件发生时(状态为就绪),这个future就不能重置了。
C++标准库中有两种future,声明在头文件中: unique future(std::future<>)和shared futures(std::shared_future<>),与了std::unique_ptr和std::shared_ptr非常类似。std::future只能与指定事件相关联,而std::shared_future就能关联多个事件。后者的实现中,所有实例会在同时变为就绪状态,并且可以访问与事件相关的数据。这种关联与模板有关,比如std::unique_ptr 和std::shared_ptr的模板参数就是相关的数据类型。与数据无关处的,可以使用std::future与std::shared_future的特化模板。虽然,我倾向于线程通讯,但future对象本身并不提供同步访问。当多个线程需要访问一个独立future对象时,必须使用互斥量或类似同步机制进行保护。不过,当多个线程对一个std::shared_future<>副本进行访问,即使同一个异步结果,也不需要同步future。
代码4.6 std::future从异步任务中获取返回值
- #include
- #include
-
- int find_the_answer_to_ltuae();
- void do_other_stuff();
- int main()
- {
- std::future<int> the_answer=std::async(find_the_answer_to_ltuae);
- do_other_stuff();
- std::cout<<"The answer is "<
get()< - }
与std::thread方式一样,std::async允许通过添加额外的调用参数,向函数传递额外的参数。第一个参数是指向成员函数的指针,第二个参数提供这个函数成员类的具体对象(是通过指针,也可以包装在std::ref中),剩余的参数可作为函数的参数传入。否则,第二个和随后的参数将作为函数的参数,或作为指定可调用对象的第一个参数。和std::thread一样,当参数为右值时,拷贝操作将使用移动的方式转移原始数据,就可以使用“只移动”类型作为函数对象和参数。
代码4.7 使用std::async向函数传递参数
- #include
- #include
- struct X
- {
- void foo(int,std::string const&);
- std::string bar(std::string const&);
- };
- X x;
- auto f1=std::async(&X::foo,&x,42,"hello"); // 调用p->foo(42, "hello"),p是指向x的指针
- auto f2=std::async(&X::bar,x,"goodbye"); // 调用tmpx.bar("goodbye"), tmpx是x的拷贝副本
- struct Y
- {
- double operator()(double);
- };
- Y y;
- auto f3=std::async(Y(),3.141); // 调用tmpy(3.141),tmpy通过Y的移动构造函数得到
- auto f4=std::async(std::ref(y),2.718); // 调用y(2.718)
- X baz(X&);
- std::async(baz,std::ref(x)); // 调用baz(x)
- class move_only
- {
- public:
- move_only();
- move_only(move_only&&)
- move_only(move_only const&) = delete;
- move_only& operator=(move_only&&);
- move_only& operator=(move_only const&) = delete;
-
- void operator()();
- };
- auto f5=std::async(move_only()); // 调用tmp(),tmp是通过std::move(move_only())构造得到
4.2.2 关联future实例和任务
std::packaged_task<>会将future与函数或可调用对象进行绑定。当调用std::packaged_task<>对象时,就会调用相关函数或可调用对象,当future状态为就绪时,会存储返回值。这可以用在构建线程池(可见第9章)或其他任务的管理中,比如:在任务所在线程上运行其他任务,或将它们串行运行在一个特殊的后台线程上。当粒度较大的操作被分解为独立的子任务时,每个子任务都可以包含在std::packaged_task<>实例中,之后将实例传递到任务调度器或线程池中。对任务细节进行抽象,调度器仅处理std::packaged_task<>实例,而非处理单独的函数。
代码4.8 std::packaged_task<>的偏特化
- template<>
- class packaged_task
string(std::vector<char>*,int)> - {
- public:
- template<typename Callable>
- explicit packaged_task(Callable&& f);
- std::future
get_future() ; - void operator()(std::vector<char>*,int);
- };
有些认为无法以简单的函数调用表达出来,还有一些认为的执行结果可能来自多个部分的代码。如何处理?这就需要用第三种方法(第一种是std::async(),第二种是std::package_task<>)创建future;借助std::promise显式的异步求值。
4.2.3 创建std::promise
std::promise提供设定值的方式(类型为T),这个类型会和后面看到的std::future对象相关联。std::promise/std::future对提供一种机制:future可以阻塞等待线程,提供数据的线程可以使用promise对相关值进行设置,并将future的状态置为“就绪”。
4.2.4 将异常保存到future中
- double square_root(double x)
- {
- if(x<0)
- {
- throw std::out_of_range(“x<0”);
- }
- return sqrt(x);
- }
- double y=square_root(-1);
- std::future<double> f=std::async(square_root,-1);
- double y=f.get();
函数作为std::async的一部分时,当调用抛出一个异常时,这个异常就会存储到future中,之后future的状态置为“就绪”,之后调用get()会抛出已存储的异常(注意:标准级别没有指定重新抛出的这个异常是原始的异常对象,还是一个拷贝。不同的编译器和库将会在这方面做出不同的选择)。将函数打包入std::packaged_task任务包后,当任务调用时,同样的事情也会发生。打包函数抛出一个异常,这个异常将存储在future中,在get()调用时会再次抛出。
当然,通过函数的显式调用,std::promise也能提供同样的功能。当存入的是异常而非数值时,就需要调用set_exception()成员函数,而非set_value()。这通常是用在一个catch块中,并作为算法的一部分。为了捕获异常,这里使用异常填充promise:
- extern std::promise<double> some_promise;
- try
- {
- some_promise.set_value(calculate_value());
- }
- catch(...)
- {
- some_promise.set_exception(std::current_exception());
- }
4.2.5 多个线程一起等待
- std::promise
p; - std::shared_future
sf(p.get_future()) ; // 1 隐式转移所有权
转移所有权是隐式的,用右值构造std::shared_future<>,得到std::future类型的实例①。
std::future的这种特性,可促进std::shared_future的使用,容器可以自动的对类型进行推断,从而初始化该类型的变量(详见附录A,A.6节)。std::future有一个share()成员函数,可用来创建新的std::shared_future ,并且可以直接转移future的所有权。这样也就能保存很多类型,并且使得代码易于修改:
- std::promise< std::map< SomeIndexType, SomeDataType, SomeComparator,
- SomeAllocator>::iterator> p;
- auto sf=p.get_future().share();
4.3 限时等待
这里介绍两种指定超时方式:一种是“时间段”,另一种是“时间点”。第一种方式,需要指定一段时间(例如,30毫秒)。第二种方式,就是指定一个时间点(例如,世界标准时间[UTC]17:30:15.045987023,2011年11月30日)。多数等待函数提供变量,对两种超时方式进行处理。处理持续时间的变量以_for作为后缀,处理绝对时间的变量以_until作为后缀。
所以,std::condition_variable的两个成员函数wait_for()和wait_until()成员函数分别有两个重载,这两个重载都与wait()成员函数的重载相关——其中一个只是等待信号触发,或超期,亦或伪唤醒,并且醒来时会使用谓词检查锁,并且只有在校验为true时才会返回(这时条件变量的条件达成),或直接超时。
4.3.1 时钟类
当时钟节拍均匀分布(无论是否与周期匹配),并且不可修改,这种时钟就称为稳定时钟。is_steady静态数据成员为true时,也表明这个时钟就是稳定的。通常情况下,因为std::chrono::system_clock可调,所以是不稳定的。这可调可能造成首次调用now()返回的时间要早于上次调用now()所返回的时间,这就违反了节拍频率的均匀分布。稳定闹钟对于计算超时很重要,所以C++标准库提供一个稳定时钟std::chrono::steady_clock。C++标准库提供的其他时钟可表示为std::chrono::system_clock,代表了系统时钟的“实际时间”,并且提供了函数,可将时间点转化为time_t类型的值。std::chrono::high_resolution_clock 可能是标准库中提供的具有最小节拍周期(因此具有最高的精度)的时钟。它实际上是typedef的另一种时钟,这些时钟和与时间相关的工具,都在库头文件中定义。
4.3.2 时长类
4.3.3 时间点类
可以通过对std::chrono::time_point<>实例进行加/减,来获得一个新的时间点,所以std::chrono::hight_resolution_clock::now() + std::chrono::nanoseconds(500)将得到500纳秒后的时间,这对于计算绝对时间来说非常方便。
也可以减去一个时间点(二者需要共享同一个时钟),结果是两个时间点的时间差。这对于代码块的计时是很有用的,例如:
- auto start=std::chrono::high_resolution_clock::now();
- do_something();
- auto stop=std::chrono::high_resolution_clock::now();
- std::cout<<”do_something() took “
- <
duration<double,std::chrono::seconds>(stop-start).count() - <<” seconds”<
4.3.4 接受超时时限的函数
当然,休眠只是超时处理的一种形式,超时可以配合条件变量和future一起使用。超时甚至可以在获取互斥锁时(当互斥量支持超时时)使用。std::mutex和std::recursive_mutex都不支持超时,而std::timed_mutex和std::recursive_timed_mutex支持超时。这两种类型也有try_lock_for()和try_lock_until()成员函数,可以在一段时期内尝试获取锁,或在指定时间点前获取互斥锁。表4.1展示了C++标准库中支持超时的函数。参数列表为“延时”(duration)必须是std::duration<>的实例,并且列出为时间点(time_point)必须是std::time_point<>的实例。
4.4 运用同步操作简化代码
4.4.1 利用future进行函数式编程
4.4.2 使用消息传递进行同步
4.4.3 符合并发技术规约的后续风格并发
假设任务产生了一个结果,并且future持有这个结果。然后,需要写一些代码来处理这个结果。使用std::future时,必须等待future的状态变为就绪态,不然就使用全阻塞函数wait(),或是使用wait_for()/wait_unitl()成员函数进行等待,而这会让代码变得非常复杂。用一句话来说“完事俱备,只等数据”,这也就是持续性的意义。为了给future添加持续性,只需要在成员函数后添加then()即可。比如:给定一个future fut,添加持续性的调用即为fut.then(continuation)。
与std::future类似 , std::experimental::future的存储值也只能检索一次。如果future处于持续使用状态,其他代码就不能访问这个furture。因此,使用fut.then()为fut添加持续性后,对原始fut的操作就是非法的。另外,调用fut.then()会返回一个新future,这个新future会持有持续性调用的结果。具体代码,如下所示:
- std::experimental::future<int> find_the_answer;
- auto fut=find_the_answer();
- auto fut2=fut.then(find_the_question);
- assert(!fut.valid());
- assert(fut2.valid());
与直接调用std::async或std::thread不同,持续性函数不需要传入参数,因为运行库已经为其定义好了参数——会传入处于就绪态的future,这个future保存了持续性触发后的结果。假设find_the_answer返回类型为int,find_the_question函数会传入std::experimental::future作为参数:
std::string find_the_question(std::experimental::future<int> the_answer);
4.4.4 后续函数的连锁调用
4.4.5 等待多个future
代码4.22 使用std::async从多个future中收集结果
- std::future
process_data(std::vector& vec) - {
- size_t const chunk_size = whatever;
- std::vector
> results; - for (auto begin=vec.begin(), end=vec.end(); beg!=end;){
- size_t const remaining_size = end - begin;
- size_t const this_chunk_size = std::min(remaining_size, chunk_size);
- results.push_back(
- std::async(process_chunk, begin, begin+this_chunk_size));
- begin += this_chunk_size;
- }
- return std::async([all_results=std::move(results)](){
- std::vector
v; - v.reserve(all_results.size());
- for (auto& f : all_results)
- {
- v.push_back(f.get()); // 1
- }
- return gather_results(v);
- });
- }
可以使用 std::experimental::when_all来避免这里的等待和切换,可以将需要等待的future传入when_all函数中,函数会返回新的future——当传入的future状态都为就绪时,新future的状态就会置为就绪,这个future可以和持续性配合起来处理其他的任务。
代码4.23 使用 std::experimental::when_all从多个future中收集结果
- std::experimental::future
process_data( - std::vector
& vec) - {
- size_t const chunk_size = whatever;
- std::vector
> results; - for (auto begin = vec.begin(), end = vec.end(); beg != end){
- size_t const remaining_size = end - begin;
- size_t const this_chunk_size = std::min(remaining_size, chunk_size);
- results.push_back(
- spawn_async(
- process_chunk, begin, begin+this_chunk_size));
- begin += this_chunk_size;
- }
- return std::experimental::when_all(
- results.begin(), results.end()).then( // 1
- [](std::future
>> ready_results){ - std::vector
> all_results = ready_results.get(); - std::vector
v; - v.reserve(all_results.size());
- for (auto& f: all_results){
- v.push_back(f.get()); // 2
- }
- return gather_results(v);
- });
- }
为了补全when_all,也有when_any。其也会产生future,当future组中任意一个为就绪态,这个新future的状态即为就绪。这对于并发性任务是一个不错的选择,也就需要为第一个就绪的线程找点事情来做。
4.4.6 运用std::experimental::when_any()函数等待多个future,直到其中之一准备就绪
- std::experimental::future<int> f1=spawn_async(func1);
- std::experimental::future
f2=spawn_async(func2); - std::experimental::future<double> f3=spawn_async(func3);
- std::experimental::future<
- std::tuple<
- std::experimental::future<int>,
- std::experimental::future
, - std::experimental::future<double>>> result=
- std::experimental::when_all(std::move(f1),std::move(f2),std::move(f3));
这个例子强调了when_any和when_all的重要性——可以通过容器中的任意std::experimental::future实例进行移动,并且通过值获取参数,因此需要显式的将future传入,或是传递一个临时变量。
有时等待的事件是一组线程,或是代码的某个特定点,亦或是协助处理一定量的数据。这种情况下,最好使用锁存器或栅栏,而非future。
4.4.7 线程闩和线程卡——并发技术规约提出的新特性
栅栏是一种可复用的同步机制,其用于一组线程间的内部同步。虽然,锁存器不在乎是哪个线程使得计数器递减——同一个线程可以对计数器递减多次,或多个线程对计数器递减一次,再或是有些线程对计数器有两次的递减——对于栅栏来说,每一个线程只能在每个周期到达栅栏一次。当线程都抵达栅栏时,会对线程进行阻塞,直到所有线程都达到栅栏处,这时阻塞将会被解除。栅栏可以复用——线程可以再次到达栅栏处,等待下一个周期的所有线程。
4.4.8 基本的线程闩类std::experimental::latch
std::experimental::latch声明在头文件中。构造std::experimental::latch时,将计数器的值作为构造函数的唯一参数。当等待的事件发生,就会调用锁存器count_down成员函数。当计数器为0时,锁存器状态变为就绪。可以调用wait成员函数对锁存器进行阻塞,直到等待的锁存器处于就绪状态。如果需要对锁存器是否就绪的状态进行检查,可调用is_ready成员函数。想要减少计数器1并阻塞直至0,则可以调用count_down_and_wait成员函数。
4.4.9 基本的线程卡类std::experimental::barrier
假设有一组线程对某些数据进行处理。每个线程都在处理独立的任务,因此在处理过程中无需同步。但当所有线程都必须处理下一个数据项前完成当前的任务时,就可以使用std::experimental::barrier来完成这项工作了。可以为同步组指定线程的数量,并为这组线程构造栅栏。当每个线程完成其处理任务时,都会到达栅栏处,并且通过调用栅栏对象的arrive_and_wait成员函数,等待小组的其他线程。当最后一个线程抵达时,所有线程将被释放,栅栏重置。组中的线程可以继续接下来的任务,或是处理下一个数据项,或是进入下一个处理阶段。
锁存器一旦就绪就会保持状态,不会有释放等待线程、重置、复用的过程。栅栏也只能用于一组线程内的同步——除非组中只有一个线程,否则无法等待栅栏就绪。可以通过显式调用栅栏对象的arrive_and_drop成员函数让线程退出组,这样就不用再受栅栏的约束,所以下一个周期到达的线程数就必须要比当前周期到达的线程数少一个了。
代码4.26 std::experimental::barrier的用法
- result_chunk process(data_chunk);
- std::vector
- divide_into_chunks(data_block data, unsigned num_threads);
-
- void process_data(data_source &source, data_sink &sink) {
- unsigned const concurrency = std::thread::hardware_concurrency();
- unsigned const num_threads = (concurrency > 0) ? concurrency : 2;
-
- std::experimental::barrier sync(num_threads);
- std::vector
threads(num_threads) ; -
- std::vector
chunks; - result_block result;
-
- for (unsigned i = 0; i < num_threads; ++i) {
- threads[i] = joining_thread([&, i] {
- while (!source.done()) { // 6
- if (!i) { // 1
- data_block current_block =
- source.get_next_data_block();
- chunks = divide_into_chunks(
- current_block, num_threads);
- }
- sync.arrive_and_wait(); // 2
- result.set_chunk(i, num_threads, process(chunks[i])); // 3
- sync.arrive_and_wait(); // 4
- if (!i) { // 5
- sink.write_data(std::move(result));
- }
- }
- });
- }
- } // 7
并发技术扩展规范不止提供了一种栅栏,与std::experimental::barrier相同, std::experimental::flex_barrier这个类型的栅栏更加的灵活。灵活之处在于,栅栏拥有完成阶段,一旦参与线程集中的所有线程都到达同步点,则由参与线程之一去执行完成阶段。
4.4.10 std::experimental::flex_barrier——std::experimental::barrier的灵活版本
- void process_data(data_source &source, data_sink &sink) {
- unsigned const concurrency = std::thread::hardware_concurrency();
- unsigned const num_threads = (concurrency > 0) ? concurrency : 2;
-
- std::vector
chunks; -
- auto split_source = [&] { // 1
- if (!source.done()) {
- data_block current_block = source.get_next_data_block();
- chunks = divide_into_chunks(current_block, num_threads);
- }
- };
-
- split_source(); // 2
-
- result_block result;
-
- std::experimental::flex_barrier sync(num_threads, [&] { // 3
- sink.write_data(std::move(result));
- split_source(); // 4
- return -1; // 5
- });
- std::vector
threads(num_threads) ; -
- for (unsigned i = 0; i < num_threads; ++i) {
- threads[i] = joining_thread([&, i] {
- while (!source.done()) { // 6
- result.set_chunk(i, num_threads, process(chunks[i]));
- sync.arrive_and_wait(); // 7
- }
- });
- }
- }
使用完整函数作为串行块是一种很强大的功能,因为这能够改变参与并行的线程数量。例如:流水线类型代码在运行时,当流水线的各级都在进行处理时,线程的数量在初始阶段和执行阶段要少于主线程处理阶段。
4.5 小结
同步操作对于用并发编程来说是很重要的一部分。如果没有同步,线程基本上就是独立的,因其任务之间的相关性,才可作为一个整体直接执行。本章讨论了各式各样的同步操作,有条件变量、future、promise、打包任务、锁存器和栅栏。还讨论了替代同步的解决方案:函数式编程,完全独立执行的函数,不会受到外部环境的影响,以及消息传递模式,以消息子系统为中介,向线程异步的发送消息和持续性方式,其指定了操作的后续任务,并由系统负责调度。
-
相关阅读:
头条三面技术四面HR,我临危不乱,顺顺利利一周拿下!
What are the differences between Webpack and JShaman?
一个登录点两个逻辑漏洞-edusrc
yamot:一款功能强大的基于Web的服务器安全监控工具
Java日志系统之Log4j2
ASP.NET Core 6框架揭秘实例演示[19]:数据加解密与哈希
Linux 系统适用范围
LeetCode高频题300. 最长递增子序列
Ubuntu磁盘扩展容量
XML文件的解析读取(详解)
-
原文地址:https://blog.csdn.net/qq_52758467/article/details/133312255