现代C++并发编程:从线程管理到内存模型的实战指南 1. 项目概述为什么我们需要“深入理解”现代C并发如果你是一名C开发者并且你的项目从未涉及过多线程、异步或者并行计算那在今天这个多核处理器普及的时代你的项目可能已经落后了。我见过太多项目初期为了追求快速上线所有逻辑都塞在单一线程里随着用户量增长或数据处理需求膨胀性能瓶颈立刻显现此时再回头重构并发架构其难度和风险不亚于推倒重来。这就是为什么“深入理解”现代C并发编程不是一个可选项而是一项必须提前储备的核心技能。所谓“现代C”通常指C11及之后的版本。在C11之前C标准库中并没有对并发编程的原生支持开发者不得不依赖操作系统特定的API如POSIX线程pthread或Windows线程API或第三方库如Boost.Thread。这不仅使得代码难以跨平台更让并发编程的正确性保障如数据竞争、死锁完全依赖于开发者的经验和细心程度堪称“刀尖上跳舞”。C11的发布是一个分水岭它将线程、互斥量、条件变量、期值future、异步任务等并发组件纳入了标准库为编写安全、高效、可移植的并发程序提供了坚实的基础设施。然而仅仅知道std::thread和std::mutex的用法远不等于“深入理解”。现代C并发编程是一个层次丰富的体系它至少包含以下几个层面线程管理的基础设施、数据共享与同步的机制、基于任务的异步编程模型以及最底层的内存模型与原子操作。每一个层面都有其精妙的设计和极易踩坑的细节。例如你知道std::async默认的启动策略std::launch::async | std::launch::deferred可能不会立即创建线程吗你了解std::atomic在x86和ARM架构上可能带来不同的性能影响吗你清楚std::condition_variable在配合谓词使用时为什么必须使用while循环而不是if语句吗本篇文章的目的就是带你穿越这些迷雾。我不会仅仅罗列API而是会结合我多年在构建高吞吐量服务、实时数据处理系统时积累的经验拆解每个核心概念背后的设计哲学、实现原理和实战中的“坑”。无论你是正在学习并发编程的初学者还是已经使用过一些并发组件但感觉理解不够透彻的中级开发者这篇文章都将为你提供一个系统性的、直击要害的视角。我们将从最基础的线程生命周期管理开始逐步深入到内存序Memory Order这样硬核的话题目标是让你不仅能写出能跑的并发代码更能写出正确、高效且易于维护的并发代码。2. 核心概念与基础设施拆解2.1 线程从创建到管理的全生命周期创建线程在C11中变得异常简单std::thread my_thread(func, arg1, arg2);。但简单背后隐藏着第一个需要“深入理解”的点线程对象与底层执行线程的生命周期是分离的。std::thread对象是一个C对象而底层线程是操作系统资源。当std::thread对象被析构时如果底层线程仍然是“可结合的”joinable即还在运行或已结束但未被join程序会直接调用std::terminate()终止。这是一个非常严厉的运行时错误。注意这是新手最容易犯的致命错误之一。务必在std::thread对象析构前明确决定其底层线程的命运是等待它结束join还是让它独立运行detach。绝大多数情况下推荐使用join因为它能保证资源的正确清理。detach通常只用于守护线程或生命周期极长的后台任务使用时要非常小心因为分离后的线程将不再受主线程控制其访问的资源必须确保在主线程结束后依然有效。除了手动管理更现代的做法是使用资源获取即初始化RAII思想来封装线程。我们可以创建一个ThreadGuard类在析构函数中自动调用join。但C标准库提供了更优雅的方案std::jthreadC20引入。它在析构时会自动join并且还内置了协作式中断请求机制通过request_stop()。如果你的项目可以使用C20应优先考虑std::jthread。// 传统方式需要小心管理 void risky_way() { std::thread t([]{ /* do work */ }); // 如果此处抛出异常或者函数提前返回t 将未被 join导致程序终止 t.join(); // 必须手动调用 } // 使用 std::jthread (C20) void safe_way() { std::jthread t([]{ /* do work */ }); // 函数结束时t 的析构函数会自动调用 join安全无忧。 }另一个关键点是线程的启动策略。当我们将一个可调用对象传递给std::thread时这个对象会被拷贝或移动到新线程的存储空间中。这意味着如果传递一个引用例如使用std::ref你必须确保被引用的对象在新线程执行期间一直有效。对于成员函数需要同时传递对象实例的指针或引用。2.2 互斥量与锁数据竞争的守卫者当多个线程需要读写共享数据时互斥量Mutex是最基本的同步原语。C标准库提供了多种互斥量std::mutex最基本的互斥量。std::recursive_mutex允许同一线程多次加锁解决递归函数中的锁需求。std::timed_mutex/std::recursive_timed_mutex支持超时尝试加锁。std::shared_mutexC17读写锁允许多个读线程同时访问。单纯使用lock()和unlock()是容易出错的因为异常或提前返回可能导致锁无法释放。因此永远优先使用RAII风格的锁管理器std::lock_guard最简单的RAII锁构造时加锁析构时解锁。适用于明确的锁作用域。std::unique_lock功能更丰富可以延迟加锁、尝试加锁、转移所有权并且可以配合条件变量使用。std::mutex mtx; std::vectorint shared_data; void unsafe_write() { mtx.lock(); shared_data.push_back(42); // 如果 push_back 抛出异常如内存不足锁将永远不会被释放 mtx.unlock(); } void safe_write() { std::lock_guardstd::mutex lock(mtx); // 构造时加锁 shared_data.push_back(42); // 无论是否发生异常lock 析构时都会自动解锁 }死锁是使用互斥量时另一个经典难题。死锁通常发生在多个线程以不同的顺序请求多个锁时。标准库提供了std::lock函数来解决这个问题它可以一次性锁定两个或更多的互斥量且保证不会死锁。std::mutex mtx1, mtx2; // 错误的顺序可能导致死锁 void thread_a_bad() { std::lock_guardstd::mutex lock1(mtx1); std::this_thread::sleep_for(std::chrono::milliseconds(1)); // 增加死锁概率 std::lock_guardstd::mutex lock2(mtx2); // ... } void thread_b_bad() { std::lock_guardstd::mutex lock2(mtx2); std::this_thread::sleep_for(std::chrono::milliseconds(1)); std::lock_guardstd::mutex lock1(mtx1); // ... } // 正确的做法使用 std::lock 一次性锁定 void thread_a_good() { std::unique_lockstd::mutex lock1(mtx1, std::defer_lock); std::unique_lockstd::mutex lock2(mtx2, std::defer_lock); std::lock(lock1, lock2); // 一次性锁定无死锁风险 // ... } // thread_b_good 使用相同的锁顺序mtx1, mtx2调用 std::lock2.3 条件变量线程间的“信号灯”互斥量解决了互斥访问的问题但线程间协作常常需要更复杂的机制一个线程需要等待某个条件成立例如任务队列非空才继续执行。这就是std::condition_variable的用武之地。条件变量必须与一个互斥量和一个共享条件通常是布尔标志或共享数据的状态一起使用。等待条件的经典模式如下std::mutex mtx; std::condition_variable cv; bool data_ready false; std::queueint data_queue; // 生产者线程 void producer() { { std::lock_guardstd::mutex lock(mtx); data_queue.push(42); data_ready true; } // 锁在这里释放 cv.notify_one(); // 通知一个等待的消费者 } // 消费者线程 void consumer() { std::unique_lockstd::mutex lock(mtx); // 等待条件成立。必须使用 while 循环以防止“虚假唤醒” while (!data_ready) { cv.wait(lock); // wait 会原子地解锁 mutex 并阻塞线程 } // 当被唤醒后锁已被重新获取且条件 data_ready 为 true auto data data_queue.front(); data_queue.pop(); // 处理 data... }这里有几个至关重要的细节while循环检查条件操作系统可能在没有其他线程调用notify的情况下唤醒等待的线程称为“虚假唤醒”。因此必须在循环中检查条件条件不成立则继续等待。std::unique_lockcondition_variable::wait只能接受std::unique_lockstd::mutex因为它需要在等待时解锁互斥量并在被唤醒后重新加锁。std::lock_guard没有这个能力。锁的作用域生产者在修改共享条件和调用notify_one时最好持有锁但这并非绝对必须。然而在notify之前释放锁如上例所示通常能带来更好的性能因为被唤醒的线程可以立即获取锁而不是等待生产者释放。C11还提供了std::condition_variable_any它可以与任何满足基本锁概念的类型一起工作但性能可能稍差。3. 基于任务的并发编程模型手动管理线程和锁是繁琐且容易出错的。现代C更鼓励使用基于任务Task-based的并发模型将关注点从“线程”转移到“任务”。核心设施是future头文件中的std::async,std::future和std::promise。3.1std::async与std::future异步执行的利器std::async是一个函数模板它尝试启动一个异步任务可能在新线程中执行也可能在调用get或wait的线程中延迟执行并返回一个std::future对象该对象最终将持有任务的返回值或异常。#include future #include iostream int compute_heavy_task() { std::this_thread::sleep_for(std::chrono::seconds(2)); return 42; } int main() { // 启动异步任务 std::futureint fut std::async(std::launch::async, compute_heavy_task); std::cout Main thread can do other work here...\n; // 获取结果如果任务未完成会阻塞等待 int result fut.get(); std::cout Result: result std::endl; return 0; }std::async的启动策略是一个需要深入理解的参数std::launch::async强制在新线程中异步执行。std::launch::deferred延迟执行直到在future上调用get()或wait()时才在调用线程中同步执行。std::launch::async | std::launch::deferred默认由实现决定。这意味着你无法确定任务是并发执行还是同步执行这会影响线程局部变量thread_local的行为和等待时间。在需要明确并发性的场景建议显式指定std::launch::async。std::future::get()只能调用一次调用后future的状态变为无效。如果需要等待任务完成而不获取结果可以使用wait()。还有wait_for()和wait_until()用于超时等待。3.2std::promise与std::packaged_task更灵活的任务控制std::async适合简单的“发射后不管”的任务。对于更复杂的场景我们需要更底层的控制。std::promise允许你在一个线程中设置一个值或异常并在另一个线程中通过与之关联的std::future来获取它。它相当于一个一次性的、单生产者单消费者的值通道。void set_value_later(std::promiseint prom) { std::this_thread::sleep_for(std::chrono::seconds(1)); prom.set_value(100); // 设置值 // prom.set_exception(std::make_exception_ptr(std::runtime_error(error))); // 或设置异常 } int main() { std::promiseint prom; std::futureint fut prom.get_future(); std::thread t(set_value_later, std::ref(prom)); std::cout Waiting for the value...\n; int value fut.get(); // 阻塞直到值被设置 std::cout Got value: value std::endl; t.join(); return 0; }std::packaged_task将一个可调用对象包装起来使其可以异步执行。它本身就是一个可调用对象调用它会执行包装的任务并将结果存储到内部的共享状态中与之关联的std::future可以获取这个结果。它非常适合将任务放入队列由线程池消费。int task_func(int x) { return x * 2; } int main() { // 包装一个函数 std::packaged_taskint(int) task(task_func); std::futureint fut task.get_future(); // 将任务移动到线程中执行 std::thread t(std::move(task), 21); t.detach(); // 或 join std::cout Result: fut.get() std::endl; // 输出 42 return 0; }std::packaged_task比std::promise更高级因为它自动处理了结果的传递。结合std::function和任务队列可以构建出强大的线程池。3.3 线程池的现代实现思路虽然C标准库至今C23仍未提供官方的线程池但利用现代C特性我们可以相对优雅地实现一个。核心组件包括一个任务队列std::queuestd::functionvoid()或std::queuestd::packaged_task...、一组工作线程、以及用于同步的互斥量和条件变量。一个简化版线程池的工作流程如下初始化时创建N个工作线程每个线程循环执行从任务队列中取出任务并执行。使用者通过submit函数提交任务一个可调用对象。提交时将任务包装并推入队列然后通知一个等待中的工作线程。如果希望获取任务返回值可以使用std::packaged_task和std::future。这里有一个关键优化点避免锁竞争。传统的单一任务队列和单一条件变量在大量任务提交时所有工作线程会在cv.wait处被同时唤醒然后竞争锁以获取任务“惊群效应”。高级的线程池实现会采用工作窃取Work-Stealing队列每个工作线程拥有自己的本地任务队列只有当本地队列为空时才尝试从其他线程的队列中“窃取”任务。这大大减少了锁的竞争。C17的std::invoke、C20的std::jthread和std::stop_token可以让线程池的实现更加安全和现代。4. 内存模型与原子操作并发编程的基石这是现代C并发中最硬核、也最容易被忽视的部分。要真正理解为什么无锁编程是困难的为什么某些看似正确的代码在多核CPU上会出错就必须理解C内存模型。4.1 数据竞争与顺序一致性在单线程世界中代码的执行顺序就是源码的顺序在编译器优化和CPU乱序执行的限制内但最终效果一致。但在多线程世界中如果没有正确的同步一个线程对共享变量的写入可能以“意想不到”的顺序被另一个线程观察到。这就是数据竞争Data Race它会导致未定义行为。// 一个典型的数据竞争例子 int x 0, y 0; // 线程1 void thread1() { x 1; // 操作A y 2; // 操作B } // 线程2 void thread2() { if (y 2) { // 操作C assert(x 1); // 操作D这个断言可能会失败 } }从线程1的视角看A先于B执行。但从线程2的视角看由于CPU缓存、指令重排等原因它可能先观察到B的结果y2后观察到A的结果x1从而导致断言失败。使用互斥量可以强制在互斥量作用域内建立“同步”关系阻止这种重排。顺序一致性Sequential Consistency是最直观的内存模型所有线程看到的整个程序的执行顺序是一致的并且每个线程内部的操作顺序与程序顺序一致。在顺序一致性下上例的断言永远不会失败。但严格保证顺序一致性会严重限制编译器和硬件的优化能力降低性能。因此C内存模型提供了更细粒度的控制。4.2std::atomic无锁编程的钥匙std::atomicT模板提供了一种进行原子操作的类型。对原子变量的操作是不可分割的不会出现数据竞争。对于内置类型如int,bool,指针通常有特化实现支持load(),store(),exchange(),compare_exchange_strong/weak等操作。std::atomicint counter{0}; void increment() { for (int i 0; i 1000; i) { counter.fetch_add(1, std::memory_order_relaxed); // 原子加1 } } int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); std::cout counter.load() std::endl; // 总是 2000 }但std::atomic的意义远不止于此。它真正的威力在于允许你指定内存顺序Memory Order这决定了非原子变量和原子操作之间的可见性顺序。4.3 内存顺序性能与正确性的权衡C定义了六种内存顺序从最强到最弱大致可分为三类顺序一致性顺序std::memory_order_seq_cst默认选项。它同时具有获取-释放语义和全局顺序一致性。性能开销最大但最不容易出错。在上面的计数器中如果我们使用seq_cst那么所有线程对counter的操作会有一个全局一致的总顺序。获取-释放顺序std::memory_order_acquire,std::memory_order_release,std::memory_order_acq_rel用于在成对的原子操作间建立“同步”关系。一个线程上的release操作如store之前的所有内存写入包括非原子变量对另一个线程上在acquire操作如load之后的读操作是可见的。这是实现“锁”或“屏障”语义的基础。std::atomicbool flag{false}; int data 0; // 非原子数据 // 线程1生产者 void producer() { data 42; // 操作1写入非原子数据 flag.store(true, std::memory_order_release); // 操作2释放存储 } // 线程2消费者 void consumer() { while (!flag.load(std::memory_order_acquire)) { // 操作3获取加载 // 忙等待 } assert(data 42); // 操作4这里断言总是成功 }在这个例子中release操作操作2和acquire操作操作3配对保证了操作1对data的写入先发生于操作2而操作2同步于操作3操作3先发生于操作4。因此线程2一定能看到data 42。如果使用memory_order_relaxed这个断言就可能失败。宽松顺序std::memory_order_relaxed只保证原子操作本身的原子性不提供任何同步或顺序保证。它是最快的但也是最难正确使用的通常只用于简单的计数器场景如上面的counter例子。选择建议除非你是在进行极低延迟的无锁数据结构开发并且完全理解其后果否则请坚持使用默认的std::memory_order_seq_cst。在需要性能优化时优先考虑使用获取-释放顺序它能在保证正确性的同时提供比顺序一致性更好的性能。宽松顺序是专家工具使用需极度谨慎。4.4 无锁数据结构简介基于原子操作和正确的内存顺序可以构建无锁Lock-Free甚至无等待Wait-Free的数据结构。无锁意味着并发访问时不会有一个线程被阻塞如等待锁但可能通过重试CAS循环来取得进展。无等待则保证每个线程都能在有限步内完成操作。实现一个无锁栈或队列是经典的挑战。以无锁栈为例其push操作的核心通常是一个比较并交换Compare-And-Swap, CAS循环templatetypename T class lock_free_stack { struct node { T data; node* next; node(const T d) : data(d), next(nullptr) {} }; std::atomicnode* head; public: void push(const T data) { node* new_node new node(data); new_node-next head.load(std::memory_order_relaxed); // CAS: 如果 head 等于 new_node-next则将 head 设为 new_node while (!head.compare_exchange_weak(new_node-next, new_node, std::memory_order_release, std::memory_order_relaxed)) { // 如果失败说明 head 被其他线程修改了new_node-next 已被更新为新的 head循环重试 } } // pop 操作更复杂还需处理“ABA问题”此处省略 };compare_exchange_weak是构建无锁算法的基石。它原子地比较原子对象的值与期望值如果相等则用新值替换它如果不相等则将期望值更新为原子对象的当前值。无锁编程极其复杂涉及到内存回收如风险指针、引用计数、ABA问题等除非有非常严格的性能需求否则通常建议使用由专家编写和验证的库如folly或boost.lockfree。5. 实战中的并发模式与性能考量理解了底层原理后我们来看看在实战中如何应用这些知识并关注性能。5.1 读者-写者问题与std::shared_mutex这是一种常见的并发模式多个线程可以同时读数据但写线程需要独占访问。使用普通的std::mutex会限制读的并发性。C17引入了std::shared_mutex来解决这个问题。#include shared_mutex std::shared_mutex rw_mutex; std::vectorint shared_data; void reader(int id) { std::shared_lockstd::shared_mutex lock(rw_mutex); // 共享锁 // 多个 reader 可以同时进入此区域 std::cout Reader id sees size: shared_data.size() std::endl; } void writer(int value) { std::unique_lockstd::shared_mutex lock(rw_mutex); // 独占锁 // 只有一个 writer 可以进入此区域且此时所有 reader 被阻塞 shared_data.push_back(value); }std::shared_lock用于获取共享读锁允许多个线程同时持有。std::unique_lock用于获取独占写锁。需要注意写者饥饿问题如果读锁持续不断写者可能永远无法获得锁。一些实现提供了公平策略来缓解。5.2 线程局部存储thread_local有时我们需要每个线程拥有自己的变量副本而不是共享。全局或静态变量加上thread_local关键字即可实现线程局部存储。thread_local int thread_specific_counter 0; void thread_func() { for (int i 0; i 5; i) { thread_specific_counter; // 每个线程操作自己独立的副本 std::cout std::this_thread::get_id() : thread_specific_counter std::endl; } }这对于连接池、随机数生成器、或任何需要避免同步开销的每线程状态非常有用。注意thread_local变量的初始化是惰性的首次使用时且析构顺序在C11中未指定在C20中有了更严格的规定。5.3 性能优化与陷阱锁的粒度锁的粒度要尽可能小。只锁住真正需要保护的共享数据并尽快释放锁。避免在持锁时进行IO操作、长时间计算或调用可能阻塞的函数。避免锁竞争如果多个线程频繁竞争同一把锁会成为性能瓶颈。解决方法包括使用更细粒度的锁如为不同的数据段使用不同的锁、使用无锁数据结构、或采用上文提到的工作窃取模式减少对全局队列的竞争。false sharing伪共享这是多核CPU上一个隐秘的性能杀手。当两个或多个线程访问同一缓存行Cache Line通常是64字节中的不同变量时即使它们逻辑上独立一个线程的写入也会导致其他线程的缓存行失效迫使CPU从内存重新加载造成巨大的性能损失。// 一个糟糕的例子两个频繁写的原子变量位于同一个缓存行 struct BadAlignment { std::atomicint a; std::atomicint b; }; // 优化使用编译器扩展或C11的 alignas 进行缓存行对齐 struct GoodAlignment { alignas(64) std::atomicint a; // 假设缓存行大小为64字节 alignas(64) std::atomicint b; };测量而不是猜测并发程序的性能行为常常反直觉。务必使用性能剖析工具如perf,VTune,valgrind --tooldrd来定位真正的热点和锁竞争点。6. 常见问题与调试技巧实录即使理解了所有概念并发编程依然 bug 频出。以下是我在调试并发程序时积累的一些经验。6.1 死锁的预防与诊断死锁的四个必要条件互斥、持有并等待、不可剥夺、循环等待。预防死锁的策略固定锁顺序所有线程以相同的全局顺序获取锁。这是最有效的方法之一。使用std::lock一次性锁定多个互斥量。使用层次锁Hierarchical Mutex为锁分配层级编号线程只能获取比当前持有锁层级更低的锁。避免嵌套锁如果设计允许尽量简化锁的持有关系。当死锁发生时如果程序挂起在Linux下可以用gdb附加到进程使用thread apply all bt命令查看所有线程的调用栈通常能发现哪些线程在等待哪些锁。一些工具如helgrindValgrind的一部分也能在运行时检测死锁。6.2 数据竞争的检测数据竞争是未定义行为可能间歇性发生极难复现和调试。编译器和工具使用-fsanitizethreadGCC/Clang的ThreadSanitizer编译和运行程序。它是一个强大的动态分析工具可以检测出绝大多数数据竞争。MSVC也有类似的/fsanitizethread选项需要特定版本。代码审查仔细检查所有共享的非原子、非常量数据确认其访问是否都受到适当的同步原语互斥量、原子操作等保护。将共享数据封装起来这是一个根本性的设计原则。将共享数据及其保护锁封装在一个类内部只通过成员函数访问并确保成员函数内部持有锁。这大大减少了无意中引入数据竞争的机会。6.3 条件变量的使用陷阱虚假唤醒前面已强调必须用while循环检查条件不能用if。丢失唤醒如果线程A在调用wait之前线程B就已经调用了notify_one那么这个通知可能会丢失导致线程A永远等待。确保“设置条件”和“发送通知”在逻辑上发生在“检查条件”和“开始等待”之前通常通过锁来保证顺序。notify_onevsnotify_allnotify_one只唤醒一个等待线程效率高但你需要确保被唤醒的线程是“正确”的即能处理当前条件。notify_all唤醒所有等待线程它们会竞争锁并重新检查条件只有一个能成功其他会继续等待。在简单的生产者-消费者模型中通常使用notify_one在条件可能被多个线程处理或状态变化需要通知所有等待者时使用notify_all。6.4 异步任务与异常std::async和std::future会传播异常。如果异步任务中抛出了未捕获的异常该异常会被存储在共享状态中当调用future::get()时这个异常会在调用线程中重新抛出。auto fut std::async(std::launch::async, []{ throw std::runtime_error(Oops from async task!); }); try { fut.get(); } catch (const std::exception e) { std::cout Caught exception: e.what() std::endl; }务必处理好异步任务中的异常否则它们可能被悄无声息地丢弃如果future被析构而未调用get或wait其析构函数会等待任务结束但如果任务有未捕获的异常std::terminate可能会被调用取决于实现。6.5 内存模型相关的诡异Bug这类Bug最难以捉摸因为它们依赖于特定的硬件架构、编译器优化和线程调度时机。症状包括程序大部分时间运行正常但在高负载、特定CPU或发布优化模式下偶尔出错。调试方法首先尝试使用最强的内存序std::memory_order_seq_cst替换所有宽松内存序看问题是否消失。如果消失说明是内存顺序问题。然后仔细分析代码中的“先发生于”关系使用获取-释放语义来建立必要的同步。工具ThreadSanitizer 也能检测到一部分由内存顺序错误导致的数据竞争。保持简单对于绝大多数应用使用互斥量进行同步就足够了它们隐式地建立了强大的内存屏障。无锁编程应留给专家和经过充分测试的库。深入理解现代C并发编程是一个从“会用”到“懂其所以然”再到“能驾驭”的漫长过程。它要求我们不仅掌握API更要理解底层硬件、编译器与标准之间的契约。从基础的线程管理到基于任务的抽象再到内存模型的深水区每一层都提供了不同的工具与权衡。我的建议是在项目中先从高级的、安全的抽象如std::async, 互斥量开始确保正确性。当性能分析表明同步开销成为瓶颈时再考虑使用更精细的工具如原子操作与特定的内存顺序。同时积极利用像ThreadSanitizer这样的工具来为你的并发代码保驾护航。记住在并发世界里正确的代码远比快速的代码重要。

相关新闻

最新新闻

Linux服务器搭建

Linux服务器搭建

一、环境准备 Docker 运行在 CentOS 7 (64-bit)上,要求系统为64位、系统内核版本为 3.10 以上。 1.查看系统版本 cat /etc/centos-release 满足docker的环境要求CentOS7.7+系统内核版本为 3.10 以上,就可以接下来的安装操作了 2.查看系统内核版本 uname -a 3.检查完后…

2026/7/16 20:52:16
C51单片机中reentrant关键字的使用,关于MULTIPLE CALL TO FUNCTION警告的问题

C51单片机中reentrant关键字的使用,关于MULTIPLE CALL TO FUNCTION警告的问题

关于可重入关键字reentrant的使用: 现象: 在一个项目中警告信息如下,提示该函数多次调用,因为该函数在串口中断和主循环中都有被调用。 影响: 如果在使用该函数期间被中断打断,而中断也调用了该函数&a…

2026/7/16 20:52:16
vue发布到npm的组件重新使用的时候样式缺失

vue发布到npm的组件重新使用的时候样式缺失

不废话,直接上办法 const { defineConfig } require(vue/cli-service) module.exports defineConfig({transpileDependencies: true,// 将css和js不分开打包,css将作为内联样式注入到js中css: {extract: false} })

2026/7/16 20:52:16
Java 继承详解?什么时候用继承?设计继承是为了什么?

Java 继承详解?什么时候用继承?设计继承是为了什么?

目录 一. 封装的意义是什么? 二. 为什么需要继承? 三. 继承是什么?如何使用? 四. 继承的好处是什么? 五. 设计继承需要注意什么? 六. 继承的特点 七. 子类到底继承了父类的哪些内容 7.1 继承内容 …

2026/7/16 20:52:16
【SpringBoot 怎么切换成其它的嵌入式 Servlet 容器】

【SpringBoot 怎么切换成其它的嵌入式 Servlet 容器】

❶ 简介 ❷ 默认 tomcat 服务器 ➌ 查看内置服务器种类 ❹ 类图 一、简介Spring Boot支持的内嵌容器有Tomcat(默认)、Jetty、Undertow 和 Reactor Netty(v2.0),借助可插拔(SPI)机制的实现&#…

2026/7/16 20:52:16
嵌入式开发者的终极选择:littlefs文件系统实战指南

嵌入式开发者的终极选择:littlefs文件系统实战指南

嵌入式开发者的终极选择:littlefs文件系统实战指南 【免费下载链接】littlefs A little fail-safe filesystem designed for microcontrollers 项目地址: https://gitcode.com/GitHub_Trending/li/littlefs 在嵌入式开发领域,数据存储一直是开发者…

2026/7/16 20:47:16

月新闻