【C++高阶系列】:线程库和多线程

【C++高阶系列】:线程库和多线程
🔥 本文专栏:c++
🌸作者主页:努力努力再努力wz

在这里插入图片描述
💪 今日博客励志语录 选择决定了方向,勇气决定了能走多远。没有勇气的选择是纸上蓝图,没有选择的勇气是迷失的航船。

★★★ 本文前置知识:

线程(上)

线程(下)


引入

在上一篇文章中,我们详细介绍了在 Linux 平台下如何进行线程管理,包括线程的创建、等待与退出等操作。具体而言,主要是通过调用 Linux 原生 pthread 线程库提供的接口,例如 pthread_createpthread_join 等。

需要注意的是,pthread 线程库所提供的接口遵循 POSIX 标准,因此主要适用于 Linux 及其他类 Unix 系统,例如 Unix 和 macOS。然而,在 Windows 平台下,我们无法直接使用 pthread 线程库来创建或管理线程,因为 Windows 提供了自己的一套线程管理接口。

C++ 作为一门跨平台语言,其编写的源代码应当能够在包括 Linux 和 Windows 在内的多种操作系统上编译和运行。无论在哪个平台,多线程编程都是常见的需求。自 C++11 标准起,C++ 正式在语言层面引入了对多线程的支持,这为我们提供了一套不依赖特定操作系统的标准线程库。本文将重点介绍 C++ 线程库的基本用法与特性。

thread

我们知道,C++ 是一门面向对象的语言,因此可以将大多数事物视为对象。C++ 线程库的设计也遵循面向对象的思想,将线程本身抽象为一个对象。具体来说,C++ 标准库中定义了 thread类,该类封装了线程的关键属性作为成员变量,并提供了线程相关操作作为成员函数,包括常见的线程等待(join)和线程分离(detach)等功能。

前文已提到,C++ 是一门跨平台的语言。这意味着 C++ 代码不仅应在 Windows 平台上编译和运行,也应支持在 Linux 等平台上正确执行。对于程序员而言,编写代码时通常不希望针对不同平台分别实现。也就是说,我们不应为每个平台单独编写一套代码,而应使同一份代码能在多种平台上运行。

此时可能有读者会提出疑问:既然 C++ 采用面向对象的设计,将线程封装为类,其线程的相关操作对应类的成员函数(如线程等待对应thread::join ),那么这些成员函数的底层实现必然会封装操作系统提供的线程接口。例如在 Linux 平台下,join 会封装pthread1_join ,而在 Windows 平台下,可能需要封装类似WaitForSingleObject 接口。因此,thread 类在不同平台下必然有不同的实现方式。因为 Linux 和 Windows 操作系统所提供的线程管理接口确实存在根本差异。

C++ 线程库确实会为不同平台提供 thread 类的多套实现。关键在于,线程库必须能够识别当前代码运行所在的平台,从而选择对应的实现,以实现跨平台能力。这一机制是通过条件编译 来实现的。

部分读者可能听说过条件编译 这一术语,但对其原理不太熟悉。下面我们简要介绍这一概念,已熟悉的读者可跳过此部分。

条件编译在逻辑上与我们熟知的if-else 条件分支语句相似,但并不完全相同。if-else 语句在程序运行时根据条件判断决定执行哪个分支,但所有分支的代码都会被编译到程序中。

而条件编译则不同,它在预处理阶段就根据预定义的条件决定是否编译某段代码。满足条件的代码段会被保留并编译,不满足条件的部分则不会被编译到目标文件中。

条件编译的基本语法使用预处理指令#if#elif#else#endif 组成。其结构如下:

#if条件1// 代码段1#elif条件2// 代码段2#else// 代码段3#endif

这里的 #if 相当于if#elif 相当于 else if#else 相当于 else#endif 标记条件编译块的结束。每个#if 必须对应一个 #endif

条件判断通常基于宏定义。可以使用defined 运算符检查某个宏是否被定义:

#ifdefined(EXITCODE)// 如果宏 EXITCODE 被定义,则编译本段#endif

也可以判断宏的具体取值:

#ifEXITCODE ==1// 如果 EXITCODE 的值为 1,则编译本段#endif

当然,我们也可以使用 #ifdef指令。 #ifdef 后面需跟上宏的名称,用于检查该宏是否已被定义。其功能与 #if defined()等价,但 #if defined() 在使用上更加灵活,因为它可以配合逻辑运算符(如 &&|| )构建复杂的条件表达式,而 #ifdef 只能检查单个宏是否定义,无法组合多个条件。

#ifdefMACRO// 等价于#ifdefined(MACRO)

需要注意的是,若条件编译指令出现在 main 函数之外,则所包含的代码只能是全局/静态变量声明、类型定义或函数定义等非执行语句,而不能是如 printfstd::cout 这样的可执行语句。因为 C++ 程序的执行入口是 main 函数,所有可执行语句必须位于函数体内。如果需要在条件编译中包含可执行语句,应将这些语句置于 main函数或其他函数内部。

需要注意的是,条件编译支持类似if-else 语句的嵌套结构。通常的嵌套逻辑是:最外层使用defined 运算符检查某个宏是否被定义;若已定义,则内层进一步判断该宏的具体取值,从而决定保留并编译哪一段代码。实现嵌套条件编译时,必须注意:每个#if 预处理指令都必须有且仅有一个对应的#endif 指令,用以标记该条件编译块的结束。在编写内层条件编译块时,务必在结尾处添加#endif,以便编译器能够正确区分内层与外层的条件编译范围,从而进行准确编译。

基于上述内容,我们可以编写一个条件编译的示例。其基本逻辑是构建三个嵌套的条件编译结构:每个条件编译结构的最外层判断宏是否被定义,内层则根据宏的取值进行分支选择。某个条件块被编译,则会执行其中的打印语句。由于打印语句属于可执行代码,这里将整个条件编译块置于 main 函数内部:

#include<iostream>#defineVERSION0intmain(){#ifdefined(VERSION)#ifVERSION ==0 std::cout <<"This is version1"<< std::endl;#elifVERSION ==1 std::cout <<"This is version2"<< std::endl;#elifVERSION ==2 std::cout <<"This is version2"<< std::endl;#else std::cout <<"unknown version"<< std::endl;#endif#endif#ifdefined(PLATFORM)#ifPLATFORM =="windows" std::cout <<"This is windows"<< std::endl;#else std::cout <<"This is Linux"<< std::endl;#endif#endif#ifdefined(DEBUGMODE)#ifDEBUGMODE ==1 std::cout <<"mode1"<< std::endl;#else std::cout <<"mode2"<< std::endl;#endif#endifreturn0;}
在这里插入图片描述

通过运行结果,我们可以进一步理解条件编译的工作机制。

理解条件编译的原理后,我们回到最初的问题:C++ 线程库是如何实现跨平台的?如前所述,C++ 线程库会为不同平台提供不同的thread 类实现,但这些实现对外提供统一的接口。这意味着无论在 Windows 还是 Linux 平台,thread 类都具有一致的joindetach 等接口,但其底层实现因平台而异,封装了各自平台的线程相关接口,这些细节对用户是透明的。

C++ 线程库识别当前运行平台的方式正是通过条件编译。不同平台的编译器(例如 Linux 下的 GCC,Windows 下的 MSVC 等)在编译 C++ 代码时,会隐式定义一个平台检测宏,用于标识代码所运行的平台。线程库在实现thread 类时,会先检查当前程序中定义的平台宏,再通过条件编译选择保留对应平台的实现代码。这正是 C++ 线程库实现跨平台兼容的核心机制。

// 在thread类的实现文件中#ifdef_WIN32#include<windows.h>#include<process.h>#elifdefined(__linux__)||defined(__unix__)#include<pthread.h>#elifdefined(__APPLE__)#include<pthread.h>#include<mach/mach.h>#endifclassthread{private:#ifdef_WIN32 HANDLE native_handle; DWORD thread_id;#elifdefined(__linux__)||defined(__APPLE__)#endifpublic:voidjoin(){#ifdef_WIN32WaitForSingleObject(native_handle, INFINITE);CloseHandle(native_handle);#elifdefined(__linux__)||defined(__APPLE__)pthread_join(native_handle,nullptr);#endif}};voiddetach(){#ifdef_WIN32CloseHandle(native_handle);#elifdefined(__linux__)||defined(__APPLE__)pthread_detach(native_handle);#endif}

在了解了 C++ 线程库实现跨平台的原理之后,接下来的内容我们将重点讨论 Linux 平台下 thread 类的具体实现。

前文已提到,C++ 是一门面向对象的语言,其核心思想是“一切皆对象”。因此,C++ 为线程也设计了对应的 thread 类。在 thread 类出现之前,在 Linux 平台下编写多线程代码时,我们通常调用 Linux 线程库提供的接口来管理线程的生命周期,例如使用pthread_create 创建线程,使用pthread_join 等待线程结束。由于 Linux 操作系统本身使用 C 语言编写,其线程管理接口自然也遵循 C 语言风格,采用的是面向过程的管理方式。

C++ 实现线程的方式是将线程封装为类。原本我们需要调用pthread_create 函数来创建线程,而现在只需直接创建一个thread 对象即可。创建线程时必须为其提供执行上下文。如果使用 pthread_create ,由于其接口是 C 风格的,我们只能传递一个函数指针,该指针所指向的全局函数或静态函数作为线程的执行入口。

而在 C++ 中,创建线程时同样需要提供执行上下文及相应参数,这部分功能由 thread 类的构造函数实现。该构造函数内部会封装 pthread_create 接口。值得注意的是,自 C++11 标准引入之后,线程的上下文不再局限于全局函数或静态函数,任何可调用对象(callable object)均可作为线程的执行入口,包括函数指针、仿函数(functor)、lambda 表达式以及函数包装器(如std::function )等。因此,C++ 的线程库不仅保留了 Linux 平台下线程的基本属性,还在此基础上进行了扩展,融入了诸多 C++ 特性。如果仅使用 Linux 原生线程接口编写多线程代码,代码风格将是纯粹的 C 风格,无法充分利用 C++ 的强大特性。由此可见,C++ 的
thread 类功能十分强大。在学习 thread 类的过程中,我们将不断对比 Linux 原生线程库的使用方式。一旦熟练掌握 thread 类的使用,你将会发现其便利性,并在今后的多线程开发中更倾向于使用 C++ 线程库,毕竟其具备跨平台支持。

thread 类的强大之处不止于此。除了支持多种可调用对象作为线程执行上下文之外,更重要的是,传统的 pthread_create 接口对线程函数的原型有严格限制:返回值必须为 void* ,参数必须为 void*。如果希望向线程函数传递多个参数,在 C 语言中通常需要将参数打包成结构体,并将结构体指针传递给线程函数,在线程函数内部再进行类型转换和解析。而在 C++ 中,我们可以直接将一个返回类型为 void,参数列表任意(包括任意类型、任意数量)的函数作为线程的执行上下文。

这意味着,在 C++ 中传递参数不再需要像传统方式那样间接进行,而是可以直接将目标函数以及其参数传递给 thread 的构造函数。从上层理解,thread 的构造函数会接收可调用对象及其参数,并将参数依次传递给可调用对象。

#include<iostream>#include<thread>voidprint(size_t i, size_t j){for(; i < j; i++){ std::cout <<"I am a thread"<< std::endl;}}intmain(){ std::thread thread1(print,0,2); thread1.join();return0;}
在这里插入图片描述

在上面的代码中,函数名print 会退化为函数指针。我们也可以利用 C++11 引入的 lambda 表达式作为线程的执行上下文,通过引用捕获的方式传递参数,实现与上述代码相同的效果:

#include<iostream>#include<thread>intmain(){ size_t i =0; size_t j =2; std::thread thread1([&](){for(; i < j; i++){ std::cout <<"I am a thread"<< std::endl;}}); thread1.join();return0;}
在这里插入图片描述

从上述示例可以看出,C++ 线程库的强大之处在于:它不仅不限制线程执行上下文函数的参数类型和数量(尽管返回值类型仍有限制),还支持多种形式的可调用对象作为执行上下文,如函数指针、仿函数和 lambda 表达式,极大提升了多线程编程的灵活性和表达力。

thread 类的这一特性十分关键。现在读者可能会好奇其底层实现原理。我们知道,thread 类底层必然会封装 Linux 线程库提供的一系列接口,而构造函数的主要功能是创建线程。因此,构造函数最终必定会调用pthread_create 函数。无论构造函数允许我们以何种方式创建线程,其底层实现最终仍会回到调用 pthread_create 这一核心步骤。

由于 pthread_create 函数只能接受返回类型为void* 、参数类型为 void* 的函数原型,Thread 的构造函数必然通过某种机制,将我们提供的线程执行函数(其返回类型为void ,参数类型任意)转换为符合 pthread_create 要求的函数形式。接下来的内容将揭示 Thread 构造函数中所使用的类型转换机制。

在这里插入图片描述


thread 类的构造函数是一个模板构造函数,包含一个固定模板参数和一个可变模板参数。这种设计并不令人意外,因为 thread 类需要支持传入任意数量和类型的参数,并将这些参数直接传递给线程执行函数。由于 thread 类无法预知用户提供的函数具体接收几个参数、各是什么类型,因此必须使用可变模板参数来接收任意参数。在理解可变模板参数的作用后,我们还需进一步探究构造函数的实现细节。

观察thread 模板构造函数的声明可知,其第一个参数为固定模板参数类型,其余为可变参数。结合thread 的规定——第一个参数必须为可调用对象(callable object)——我们可以推断,固定模板参数用于推导可调用对象的类型。例如,仿函数(functor)本质上是一个类,其中定义了operator() ,因此传入仿函数时,固定模板参数将被实例化为该仿函数类的类型。Lambda 表达式在底层也被编译为一个仿函数类,其捕获的变量成为该类的成员变量,因而传入 lambda 时,固定模板参数也会被实例化为对应的仿函数类型。

除第一个参数外,其余传入构造函数的参数会被推导其类型,并将类型与值分别打包到类型参数包和实参包中。接下来的关键在于,thread 构造函数如何将这些参数包传递给可调用对象。

thread 类内部定义了一个抽象基类,该类没有成员变量,仅包含一个纯虚函数:

classthread{private://......structthreadData{virtualvoidrun()=0;virtual~threadData()=default;};//.....};

作为抽象类,threadData 必须被派生类继承并实现其纯虚函数。Thread 使用一个派生类 threadDataImpl,它同样具有固定模板参数和可变模板参数,并重写基类的虚函数。该派生类包含两个成员变量:一个是固定模板参数类型的可调用对象,另一个是std::tuple 容器,用于存储参数包中的参数。

template<typenameFunction,typename... Args>structthreadDataImpl:threadData{ Function func; std::tuple<Args...> args;//.....};

threadDataImpl 类充当了一个包装器(wrapper)。为了理解其作用,我们需要简要介绍 std::tuple 容器。

std::tuple 能够存储任意数量、任意类型的元素,其实现依赖于可变模板参数和递归继承。由于 tuple 需要处理类型和数量不定的参数,其定义通常包含一个主模板(处理至少一个参数)和一个特化的空 tuple(作为递归终止条件):

// 空tuple特化 - 递归终止条件template<>classtuple<>{};// 主模板 - 递归继承template<typenameT,typename... Ts>classtuple<T, Ts...>:private tuple<Ts...>{private: T head;// 存储当前元素public:// 构造函数:第一个参数初始化value,其余传递给基类tuple(const T& first,const Ts&... rest):head(first),tuple<Ts...>(rest...){}// 获取当前元素 T&get(){return value;}// 获取基类(剩余元素的tuple) tuple<Ts...>&get_rest(){return*this;}};

tuple通过递归继承实现存储任意数量元素的魔法。其构造函数将第一个实参用于初始化当前类的成员变量,其余参数递归传递给基类tuple的构造函数。当参数包为空时,递归终止于空tuple特化版。以下展示了tuple的内存布局示例:

tuple<int,double, string> │ ├── inthead(42) └── tuple<double, string>(基类) │ ├── doublehead(3.14) └── tuple<string>(基类) │ ├── string head("hello") └── tuple<>(基类,空)// 编译器实例化过程: tuple<int,double, std::string>// 最外层: tuple<double, std::string>// 第一次递归继承: tuple<std::string>// 第二次递归继承 : tuple<>// 递归终止

在 thread 构造函数中,会创建一个 threadDataImpl 对象,其中可调用对象用于初始化 func 成员,参数包则被存入 tuple 容器。由于构造函数参数使用万能引用(universal reference)以保持值类别(value category),在初始化 threadDataImpl 成员时需使用std::forward进行完美转发:

classthread{public:// 模板构造函数template<typenameFunction,typename... Args>thread(Function&& func, Args&&... args){auto data =newthreadDataImpl<Function, Args...>( std::forward<Function>(func), std::forward<Args>(args)...);//...... }//.......private:template<typenameFunction,typename... Args>structthreadDataImpl:threadData{ Function func; std::tuple<Args...> args;threadDataImpl(Function&& f, Args&&... a):func(std::forward<Function>(f)),args(std::forward<Args>(a)...){}voidrun()override{ std::apply(func, args);}};};

关键步骤在于,threadDataImpl 重写的 run() 虚函数中,使用std::apply 将 tuple 中的参数解包并传递给可调用对象。std::tuple 的底层实现较为复杂,此处不展开讨论,作为使用者,我们只需了解其功能为“将tuple 容器存储元素作为参数依次传递给可调用对象”即可。

thread 构造函数最终仍需调用pthread_create 创建线程。由于 pthread_create要求函数签名必须为 "void* ()(void * )",而类的非静态成员函数隐含 this 指针,不能直接满足要求。因此,thread 类定义一个静态成员函数作为代理(proxy),该函数签名符合要求,并通过传入的 void 参数获取 threadData 对象,调用其 run() 方法:

classthread{private: pthread_t thread_id;// 打包器基类structthreadData{virtualvoidrun()=0;virtual~ThreadData()=default;};// 具体打包器template<typenameFunction,typename... Args>structthreadDataImpl:threadData{ Function func; std::tuple<Args...> args;threadDataImpl(Function&& f, Args&&... a):func(std::forward<Function>(f)),args(std::forward<Args>(a)...){}voidrun()override{ std::apply(func, args);}};// 静态代理函数staticvoid*thread_proxy(void* arg){ threadData* data =static_cast<threadData*>(arg); data->run();delete data;returnnullptr;}public:// 模板构造函数template<typenameFunction,typename... Args>thread(Function&& func, Args&&... args){auto data =newthreadDataImpl<Function, Args...>( std::forward<Function>(func), std::forward<Args>(args)...);if(pthread_create(&thread_id,nullptr, thread_proxy, data)!=0){delete data;throw std::runtime_error("创建线程失败");}}// 禁止拷贝Thread(const Thread&)=delete; Thread&operator=(const Thread&)=delete;};

以上代码展示了 thread 类如何通过模板、类型擦除和代理函数等机制,将用户提供的任意可调用对象适配为符合 POSIX 线程库要求的函数形式,从而实现类型安全的线程创建。

在理解了thread 构造函数的原理后,接下来我们继续探讨thread 类的其他构造细节。

创建线程后,通常需要等待其执行结束。在Linux平台下,等待线程通过调用pthread_join 函数实现,该函数接受一个pthread_t 类型的参数作为线程标识。类似地,当我们创建一个thread 对象时,其内部封装的线程实际上在对象构造完成后即开始执行。要等待该线程,可调用tthread 类提供的join 方法,而该方法底层必然封装了pthread_join 接口。

这意味着thread类内部必须保存所创建线程的ID。因此,该类会维护一个pthread_t类型的成员变量。在构造函数调用pthread_create创建线程时,该成员变量会被初始化。除了线程ID,thread类通常还会维护一个状态字段,用于标识线程的当前状态。

由于一个线程可被等待(joinable)也可被分离(detached),但一旦被分离,便不能再被等待;同样,一旦被等待,也不能再被分离。因此,thread类需要借助一个状态字段来管理线程的生命周期。常见的实现方式是使用枚举类型定义几种状态。例如:

enumThreadState{ Joinable =0, Detached, Joined };classthread{private: pthread_t thread_id; ThreadState _state;// ...};

这里定义了三个枚举常量:Joinable 表示线程可被等待,Detached 表示线程已分离,Joined 表示线程已被等待过。

基于此,我们可以理解join 方法的实现原理:首先检查当前状态字段是否为joinable ,若不是,则等待失败,应抛出异常。此外,还需检查thread_id 是否有效。因为即使线程已被成功等待(即已调用过join ),thread对象仍可能存在,若再次调用join ,将导致错误。例如:

#include<iostream>#include<thread>intmain(){ size_t i =0; size_t j =2; std::thread thread1([&](){for(; i < j; i++){ std::cout <<"I am a thread"<< std::endl;}}); thread1.join(); thread1.join();// 错误:重复等待return0;}

为避免重复等待,线程在成功被等待后,不仅状态应更新为joinedthread_id 也应被重置为默认值(如0),表示该thread对象不再关联任何有效线程:

void thread::join(){if(!joinable()|| thread_id_ ==0){throw std::runtime_error("Thread is not joinable");}int result =pthread_join(thread_id_,nullptr);if(result !=0){throw std::runtime_error("pthread_join failed");} state_ = ThreadState::JOINED; thread_id_ =0;// 标记为无效}

类似地,detach 方法的实现也需先检查状态与thread_id 的有效性。若线程为Joinable 状态且thread_id 有效,则调用pthread_detach 并更新状态为Detached ,否则抛出异常:

void thread::detach(){if(!joinable()|| thread_id_ ==0){throw std::runtime_error("Thread is not detachable");}int result =pthread_detach(thread_id_);if(result !=0){throw std::runtime_error("pthread_detach failed");} state_ = ThreadState::DETACHED;// 注意: detach后thread_id仍然有效, 但不能join}

值得注意的是,thread类一般会禁用左值版本的拷贝构造函数和拷贝赋值运算符。这主要有两个目的:一是防止同一线程被多次等待,二是避免多线程并发访问导致的数据竞争问题。

然而,右值版本的移动构造函数和移动赋值运算符通常被允许。由于右值生命周期短暂,移动语义通过浅拷贝实现资源所有权的转移。例如,可以通过临时thread对象(右值)初始化另一个thread对象,此时调用移动构造函数,复制内部成员(如thread_id )并将原对象的thread_id 置为无效:

thread(thread&& other)noexcept:thread_id(other.thread_id)// 浅拷贝线程ID{ other.thread_id =0;// 将源对象置为"空线程"状态} thread&operator=(thread&& other)noexcept{if(this!=&other){// 先检查当前对象是否有关联线程if(joinable()){ std::terminate();// 或执行其他清理逻辑}// 转移资源所有权 thread_id = other.thread_id; other.thread_id =0;}return*this;}

在移动赋值过程中,若当前对象已关联线程,通常需要先终止该线程,再接管右值对象的线程资源。

接着需要介绍的是thread 类的析构函数。thread 类的析构函数会检查当前对象所关联的线程是否已被正确管理,即通过判断其内部状态是否为可连接(Joinable)。如果线程处于可连接状态,则析构函数会调用std::terminate
std::terminate 不仅会终止当前线程,还会导致整个进程终止,因此必须确保在thread 对象析构之前,其所关联的线程已被正确等待(joined)或分离(detached)。

thread::~thread(){if(joinable()){ std::terminate();}}

了解了如何创建和管理线程后,我们知道在 Linux 平台下,每个线程都有唯一的一个标识符用于区分。通过打印线程标识符,我们可以识别不同的线程。前文提到,std::thread 类内部维护了一个原生的线程标识符,即pthread_t 类型的成员变量。同时,thread 类提供了native_handle() 方法,用于返回该类所维护的原生线程 ID。

进一步观察thread 类的设计,可以发现除了原生线程 ID 之外,其内部还维护了一个id 类的成员变量。这两个成员变量的含义相同,均表示线程的标识符。有读者可能会产生疑问:既然thread 类已持有一个pthread_t 类型的原生线程 ID,为何还要额外维护一个 id 类对象?这样的设计是否会显得冗余?

在这里插入图片描述

下面我们来分析 id 类。 thread 类内部定义了一个嵌套类——id 类,其成员变量正是原生线程 ID,即 pthread_t 类型变量。不过,我们关注的重点并不在于 id 类的成员变量,而在于其成员函数。可以发现,id 类提供了大量用于线程比较的运算符重载函数。前文已强调,C++ 是一门跨平台语言,需支持在 Windows、Linux 等不同平台上正确运行。

classthread{private: Id _id; pthread_t native_handle_ public:classId{private: pthread_t native_id;public:Id()noexcept:native_id(0){}explicitId(pthread_t id)noexcept:native_id(id){}booloperator==(const Id& other)constnoexcept{returnpthread_equal(native_id, other.native_id)!=0;}booloperator!=(const Id& other)constnoexcept{return!(*this== other);}booloperator<(const Id& other)constnoexcept{return native_id < other.native_id;} std::string to_string()const{ std::ostringstream oss; oss << native_id;return oss.str();}//.....};//.........};

在不同平台下, thread 类内部维护的原生线程 ID 类型可能存在差异。C++ 引入 id 类的目的,正是为了屏蔽底层类型的差异,以统一的视角处理线程 ID。通过 id 类,可以抽象表示各平台下的线程 ID。在需要比较线程 ID 时,我们不应直接使用原生线程 ID 进行比较,而应调用 id 类所提供的运算符重载函数。

thread 类提供了 get_id() 方法,其返回值即为内部维护的 id 类类型变量。通过该方法,我们可以获取线程 ID,并进一步调用相应的运算符重载函数完成各种比较操作。

此外,id 类还重载了流插入运算符( operator<< ),从而能够正确输出 ID 的值。

#include<iostream>#include<thread>intmain(){ std::thread thread1([](){int a =1;int b =2; a + b;}); std::thread thread2([](){int a =1;int b =2; a + b;});if(thread1.get_id()> thread2.get_id()){ std::cout <<"thread 1 id :"<< thread1.get_id()<<" > "<<"thread 2 id :"<< thread2.get_id()<< std::endl;}else{ std::cout <<"thread 2 id :"<< thread2.get_id()<<" > "<<"thread 1 id :"<< thread1.get_id()<< std::endl;} thread1.join(); thread2.join();return0;}
在这里插入图片描述

了解 id 类的作用后,我们知道可以调用 get_id() 方法获取 thread 对象所关联的线程 ID。不过,这种方式仅限于创建线程的上下文中调用,因为需要持有 thread 对象。如果希望在线程执行的上下文中获取当前线程的 ID,则需要调用 this_thread 命名空间下的 get_id() 方法。

this_thread 是一个命名空间,提供了一系列与当前线程相关的函数,其中包括 get_id() 。该方法的底层实现较为简单:调用线程库的 pthread_self() 接口获取 pthread_t 类型的线程 ID,再通过该原生 ID 构造一个 id 类对象并返回。

// this_thread命名空间 - 提供线程相关操作namespace this_thread {// 获取当前线程IDinline Thread::Id get_id()noexcept{returnThread::Id(pthread_self());}//....}

除了 get_id() 方法,this_thread 命名空间中还有一个重要方法—— yield() 。我们只需理解其含义及用法,无需掌握具体实现细节。 yield() 方法的作用是让出当前 CPU 时间片。需要注意的是,调用 yield() 后,线程并不会被移出就绪队列进入阻塞状态,而是主动释放当前占用的时间片,使 CPU 能够切换到其他线程执行。yield() 常与自旋锁结合使用。由于自旋锁需要通过忙等待(busy-waiting)不断检查锁状态,在单核 CPU 环境下,若某个线程持有锁但在执行过程中被切换,而调度到另一个正在自旋等待锁的线程,此时会出现无效自旋:因为单核 CPU 同一时刻只能执行一个线程,自旋线程占用 CPU 时间,却无法使持有锁的线程获得执行机会以释放锁。这种情况下,在自旋过程中,若检测到锁仍未释放,可调用 yield() 主动让出 CPU,以便持有锁的线程得以执行并释放锁。

std::mutex _mutex;voidthreadfun(){while(!_mutex.try_lock()){ std::this_thread::yield();}//临界区代码 _mutex.unlock();}

mutex

我们知道,当多个线程并发访问同一个共享资源且访问操作非原子时,会出现数据不一致的问题。解决方案是确保对共享资源的访问是互斥的,因此需要引入互斥锁(mutex),以保证线程对临界区代码段的访问是串行执行的。

在 Linux 平台下,实现互斥访问通常通过定义 pthread_mutex_t 类型的互斥锁变量,并调用线程库提供的相关接口,如 pthread_mutex_lockpthread_mutex_unlock 进行加锁与解锁。C++ 标准库也提供了对互斥锁的支持。正如前文所述,C++ 是一门面向对象的语言,因此其将互斥锁设计为类,将对 mutex 的相关操作封装为类的成员函数。

与之前讨论的 thread 类相比,C++ 中 mutex 类的实现要简单许多。其设计思路类似于 STL 中栈和队列所采用的容器适配器模式:mutex 类内部封装一个 pthread_mutex_t 类型的变量,其成员函数如lock()unlock() 等直接调用底层线程库的对应函数。构造函数中调用 pthread_mutex_init 完成初始化,析构函数中调用pthread_mutex_destroy 进行资源释放。

classmutex{private: pthread_mutex_t m_mutex;// 底层 POSIX 互斥锁public:// 构造函数 - 初始化互斥锁mutex(){pthread_mutex_init(&m_mutex,nullptr);}// 析构函数 - 销毁互斥锁~mutex(){pthread_mutex_destroy(&m_mutex);}// 加锁voidlock(){pthread_mutex_lock(&m_mutex);}// 解锁voidunlock(){pthread_mutex_unlock(&m_mutex);}// 尝试加锁(非阻塞)booltry_lock(){returnpthread_mutex_trylock(&m_mutex)==0;}// 禁止拷贝(重要!)mutex(const mutex&)=delete; mutex&operator=(const mutex&)=delete;};

需要注意的是,与thread 类类似,mutex 类也应禁止拷贝构造和赋值操作,以避免互斥锁被复制导致重复释放或未定义行为。

在了解了 mutex 类的基本用法后,当多个线程需要并发访问同一共享资源时,我们可以定义一个 mutex 对象,并调用其 lock 函数对临界区代码段进行加锁。但在 C++ 编程中,如果代码中引入了异常处理逻辑,就需要额外的考虑。

假设我们创建一个线程,在该线程上下文中调用函数 fun ,而 fun 内部需要访问临界区。为了保证互斥访问,应在 fun 函数中对临界区加锁。若在加锁之后、解锁之前,fun 函数抛出异常,则必须通过 try-catch 语句块捕获该异常。一旦异常抛出,程序会首先检查当前函数作用域是否定义了匹配的 try-catch 块;若没有,则沿着函数调用链向上查找。如果异常抛出后,fun 函数或其外层函数未定义 try-catch 块,或 catch 块中未进行解锁操作,将导致死锁问题。

voidfun(){ mutex.lock();// ...throw exception;//... mutex.unlock();}voidthreadfun(){try{fun();}catch(exception& a){ std::cout << a.what()<< std::endl;}}

解决上述问题的最佳方式是采用 RAII(Resource Acquisition Is Initialization)机制。RAII 的核心思想是将资源的生命周期与对象绑定,实现自动管理。具体到锁的管理,即加锁与解锁操作由对象的构造和析构自动完成,从而避免手动管理可能出现的遗漏。为此,C++ 提供了基于 RAII 思想的 lock_guard 类。

在这里插入图片描述

lock_guard 类的设计简洁明确:其内部维护一个 mutex 对象,仅包含构造函数和析构函数,不提供其他成员函数。构造函数中调用 mutex 的 lock 方法进行加锁,析构函数中调用 unlock 方法进行解锁。当函数执行结束或因异常退出时,栈帧中的局部对象会按照构造顺序的逆序析构。因此,即使临界区代码抛出异常,
lock_guard 对象的析构函数也会被调用,确保锁被正确释放,从而避免死锁。

然而,lock_guard 也存在一定的局限性:其加锁和解锁的时机是固定的——加锁发生在构造函数执行完毕时,解锁发生在析构函数被调用时(通常是函数退出时)。但在某些场景下,我们可能需要更灵活地控制加锁与解锁的时机。为此,C++ 提供了unique_lock 类,同样基于 RAII 思想,但在锁的管理上提供了更高的可控性。

在这里插入图片描述

unique_lock 的实现相对复杂,主要体现在其构造函数支持多种加锁策略。具体而言,unique_lock 提供以下三种典型的加锁策略:

  1. 立即加锁策略:在构造 unique_lock 对象时立即加锁,通过只接受一个 mutex 参数的构造函数实现,功能与lock_guard 类似。
  2. 延迟加锁策略:构造时不加锁,后续通过调用 lock 方法手动加锁。
  3. 接管已加锁的 mutex:适用于 mutex 已被当前线程锁定的场景,unique_lock 对象直接接管该锁的所有权,并在析构时负责释放。

为了区分不同的加锁策略,unique_lock 的构造函数支持传入第二个参数,类型为特定的标记类(空结构体),包括:

  • defer_lock_t :表示延迟加锁;
  • adopt_lock_t :表示接管已加锁的 mutex。

C++ 标准库提供了这些标记类的全局常量对象(如std::defer_lockstd::adopt_lock ),用于明确指定加锁策略。

// 标记类定义示例structdefer_lock_t{explicitdefer_lock_t()=default;};structadopt_lock_t{explicitadopt_lock_t()=default;};// 全局常量标记对象inlineconstexpr defer_lock_t defer_lock{};inlineconstexpr adopt_lock_t adopt_lock{};

在实际使用中,可根据需求选择适当的加锁策略:

std::mutex mtx;voidsafe_operation(){// 使用延迟加锁策略 std::unique_lock<std::mutex>lock(mtx, std::defer_lock);// 执行不需要加锁的预处理preprocess_data();// 手动加锁 lock.lock();// 临界区代码 lock.unlock();// 可提前解锁// 执行其他操作...}

unique_lock 内部除维护一个 mutex 对象指针外,还包含一个状态标志own_lock ,用于指示当前对象是否拥有锁的所有权。其成员函数(如lockunlock )和析构函数均会检查该状态标志,确保加锁和解锁操作的正确性。例如,lock 函数会检查当前未加锁且 mutex 有效时才执行加锁,并将 owns_lock 置为true ;析构函数则在owns_locktrue 时自动释放锁。

classunique_lock{private: mutex_type* mutex_ptr;bool owns_lock;// 状态标志:是否拥有锁的所有权public:// 立即加锁构造函数unique_lock(mutex_type& m):mutex_ptr(&m),owns_lock(true){ mutex_ptr->lock();}// 延迟加锁构造函数unique_lock(mutex_type& m, std::defer_lock_t):mutex_ptr(&m),owns_lock(false){}// 接管已加锁的构造函数unique_lock(mutex_type& m, std::adopt_lock_t):mutex_ptr(&m),owns_lock(true){}~unique_lock(){if(owns_lock && mutex_ptr){ mutex_ptr->unlock();}}voidlock(){if(mutex_ptr &&!owns_lock){ mutex_ptr->lock(); owns_lock =true;}}voidunlock(){if(mutex_ptr && owns_lock){ mutex_ptr->unlock(); owns_lock =false;}}};

通过上述机制,unique_lock 在保持 RAII 安全性的同时,提供了更灵活的锁管理方式,适用于复杂的同步需求。

在介绍完C++中最基本的互斥锁以及基于RAII思想的lock_guardunique_lock 之后,接下来我们将探讨C++标准库提供的其他类型的锁。

在这里插入图片描述

首先要介绍的是递归锁(recursive mutex)。从名称可以推测,这种锁适用于递归调用的场景。假设某个线程执行的上下文是一个递归函数,且该函数需要访问临界区代码段,那么就需要进行加锁。但如果临界区内的代码又会递归调用该函数,则在递归进入下一层时,会再次尝试获取锁。然而此时锁已被上一层的调用持有,导致本次加锁失败,线程陷入阻塞,最终形成死锁。

std::mutex _mutex;voidfun(size_t n){if(n ==0){return;} _mutex.lock();// ... 临界区代码fun(n -1);// 递归调用,再次尝试获取已持有的锁,导致死锁// ... _mutex.unlock();}

为了解决这个问题,可以使用递归锁。递归锁在使用接口上与普通互斥锁基本一致,都提供了lock
unlock 等方法。其原理在于内部维护了当前持有锁的线程标识、加锁次数(引用计数)以及一个条件变量。每次加锁时,如果当前线程与锁的持有线程相同,则增加引用计数并立即返回;否则线程将阻塞,直到引用计数降为零后被唤醒。解锁时则减少引用计数,当计数归零时释放锁,并唤醒等待中的线程。

// 伪代码实现classrecursive_mutex{private: std::thread::id owner_thread;// 当前持有锁的线程IDint recursion_count =0;// 递归计数 std::mutex internal_mutex;// 内部互斥锁 std::condition_variable cv;// 条件变量,用于线程等待public:voidlock(){auto this_thread = std::this_thread::get_id(); std::unique_lock<std::mutex>lock(internal_mutex);if(recursion_count >0&& owner_thread == this_thread){// 同一线程重复加锁,增加计数 recursion_count++;}else{// 等待其他线程释放锁while(recursion_count >0){ cv.wait(lock);} owner_thread = this_thread; recursion_count =1;}}voidunlock(){ std::unique_lock<std::mutex>lock(internal_mutex);if(--recursion_count ==0){ owner_thread = std::thread::id();// 清空持有线程 cv.notify_one();// 唤醒一个等待线程}}};

除了递归锁,C++还提供了定时锁(timed mutex)。与普通互斥锁不同,当一个线程竞争互斥锁失败时,它会被移出就绪队列并进入阻塞状态。而定时锁在竞争失败时不会立即阻塞,而是会在指定时间内重复尝试获取锁。与自旋锁(spinlock)不同的是,自旋锁会持续占用CPU进行忙等待,而定时锁允许设置一个超时时间。若在超时时间内未成功获取锁,线程将主动放弃,避免长时间空转。

在这里插入图片描述

定时锁通过try_lock_fortry_lock_until 两个成员函数实现超时机制。这两个函数需传入一个std::chrono::duration 对象(表示时间间隔)或std::chrono::time_point 对象(表示时间点)。
std::chrono::duration 是一个模板类,可用于构造不同精度的时间间隔:

#include<chrono>intmain(){usingnamespace std::chrono; milliseconds ms(500);// 500毫秒 seconds sec(2);// 2秒 microseconds us(100000);// 100毫秒(即100,000微秒)auto total_time = ms + sec;// 可进行算术运算,结果为2.5秒return0;}

构造时间间隔对象后,可将其传入try_lock_for() ,指定线程尝试获取锁的最大时长。若在期间内成功获取锁,函数返回true ,否则返回false

#include<mutex>#include<chrono>#include<thread> std::timed_mutex tmtx;voidworker(int id){usingnamespace std::chrono; milliseconds timeout(50);if(tmtx.try_lock_for(timeout)){ std::cout <<"线程 "<< id <<" 获取锁成功"<< std::endl;// 执行临界区代码 tmtx.unlock();}else{ std::cout <<"线程 "<< id <<" 获取锁超时"<< std::endl;}}

另一种方式是使用try_lock_until ,它接受一个时间点参数,表示获取锁的截止时刻。时间点可通过当前时间加上时间间隔得到。C++的<chrono> 库提供了三种时钟类型:

  • system_clock :系统时钟,可能随用户调整而变化,反映实际时间。
  • steady_clock :稳定时钟,保证单调递增,不受系统时间调整影响。
  • high_resolution_clock :高精度时钟,提供最小 tick 间隔。
#include<chrono>#include<mutex>voidexample(){usingnamespace std::chrono; std::timed_mutex tmtx;// 使用稳定时钟,当前时间 + 500毫秒auto steady_timeout = steady_clock::now()+milliseconds(500);if(tmtx.try_lock_until(steady_timeout)){// 临界区代码 tmtx.unlock();}}

总体而言,定时锁在互斥锁的阻塞机制和自旋锁的忙等待之间提供了一种折衷方案。它通过超时机制有效降低了死锁风险,适用于对响应时间有要求的并发场景。

condition_variable

接下来是关于C++条件变量实现的说明。我们知道C++标准库将条件变量封装为condition_variable类,该类内部封装了一个原生条件变量(如pthread_cond_t )。其构造函数通过调用pthread_cond_init 完成初始化,析构函数则通过pthread_cond_destroy 进行资源释放。

condition_variable 类的核心成员函数包括notify_onenotify_all。其中,notify_one 用于唤醒在该条件变量等待队列中的一个线程,而notify_all则唤醒所有等待线程。它们的底层实现分别对应于
pthread_cond_signalpthread_cond_broadcast

wait 函数用于在条件不满足或资源未就绪时,使当前线程释放锁并进入阻塞状态,同时将其加入条件变量的等待队列。其底层通过调用pthread_cond_wait 实现。

classcondition_variable{private: pthread_cond_t _M_cond;// 封装的原生条件变量public:// 构造函数condition_variable(){int __e =pthread_cond_init(&_M_cond,nullptr);if(__e){// 错误处理逻辑}}condition_variable(const condition_variable&)=delete;voidwait(std::unique_lock<std::mutex>& lock){pthread_cond_wait(&_M_cond, lock.mutex()->native_handle());}voidnotify_one()noexcept{pthread_cond_signal(&_M_cond);}voidnotify_all()noexcept{pthread_cond_broadcast(&_M_cond);}// 析构函数 ~condition_variable(){pthread_cond_destroy(&_M_cond);}};

需要注意的是,观察condotion_variable::wait 函数的原型可知,它仅能接受std::unique_lock<std::mutex> 类型的锁。若希望条件变量能够与任意类型的锁配合使用,应使用std::condition_variable_any 类。其wait 函数为模板函数,可适配满足BasicLockable要求的任何锁类型。

std::condition_variable_any cv_any;// 通用条件变量// 1. 与 unique_lock<mutex> 配合 std::mutex mtx1; std::unique_lock<std::mutex>lock1(mtx1); cv_any.wait(lock1);// ✅ 正确// 2. 与 unique_lock<timed_mutex> 配合  std::timed_mutex tmtx; std::unique_lock<std::timed_mutex>lock2(tmtx); cv_any.wait(lock2);// ✅ 正确// 3. 与自定义锁类型配合(需实现 lock()/unlock() 接口)classMyLock{ std::mutex mtx;public:voidlock(){ mtx.lock();}voidunlock(){ mtx.unlock();}}; MyLock my_lock; cv_any.wait(my_lock);// ✅ 正确

应用

上文介绍了C++中的线程、互斥锁和条件变量的基本概念,接下来我们将应用这些知识解决一个常见的面试问题:创建两个线程,使其交替打印10以内的奇数和偶数。

仔细分析题目,实现的关键在于"交替"二字。我们将创建两个线程,一个负责打印奇数,另一个负责打印偶数。这里我们定义一个初始值为0的全局整型变量作为共享资源,供两个线程并发访问。

问题的难点在于,两个线程是独立的执行流。一旦某个线程被调度,在其时间片内会持续执行,导致共享变量被连续递增。但我们需要保证交替打印:例如,当打印偶数的线程发现当前值为偶数时,应打印并将值递增为奇数,然后让出执行权,等待另一个线程打印奇数。

因此,我们可以确定基本实现思路:

  1. 两个线程需要互斥地访问共享变量,因此需要一把互斥锁
  2. 需要同步机制确保线程在条件不满足时等待,条件满足时继续执行。具体来说:
    • 打印偶数的线程获取锁后,若发现当前值为奇数(条件不满足),应释放锁并阻塞
    • 当打印奇数的线程将值递增为偶数后,应通知等待的偶数线程
    • 奇数线程的逻辑与此对称

需要注意的是,在值递增后,无论另一个线程是否处于等待状态,都应进行通知。若另一个线程尚未被调度(即未因条件不满足而阻塞),则通知操作不会有实际影响;若其已被调度并处于等待状态,则会被唤醒并重新竞争锁。

基于上述分析,我们给出第一种实现方案:使用一把互斥锁和两个条件变量,分别对应奇数线程和偶数线程的等待条件。每个线程的执行逻辑如下:

  1. 获取互斥锁
  2. 检查条件是否满足(偶数线程检查当前值是否为偶数,奇数线程检查是否为奇数)
  3. 若条件不满足,则释放锁并阻塞在对应的条件变量上
  4. 若条件满足,则打印当前值并递增
  5. 通知另一个线程(通过对应的条件变量)
  6. 释放锁

以下是具体实现代码:

#include<iostream>#include<thread>#include<mutex>#include<condition_variable> std::mutex mutex; std::condition_variable cond_even;// 偶数线程条件变量 std::condition_variable cond_odd;// 奇数线程条件变量int counter =0;// 共享计数器voidprint_even(size_t max_num){while(true){ std::unique_lock<std::mutex>lock(mutex);// 等待条件满足:计数器未超过最大值且为偶数while(counter < max_num && counter %2!=0){ cond_even.wait(lock);}if(counter >= max_num){ cond_odd.notify_one();break;} std::cout <<"Even thread: "<< counter << std::endl;++counter; cond_odd.notify_one();// 通知奇数线程}}voidprint_odd(size_t max_num){while(true){ std::unique_lock<std::mutex>lock(mutex);// 等待条件满足:计数器未超过最大值且为奇数while(counter < max_num && counter %2!=1){ cond_odd.wait(lock);}if(counter >= max_num){ cond_even.notify_one();break;} std::cout <<"Odd thread: "<< counter << std::endl;++counter; cond_even.notify_one();// 通知偶数线程}}intmain(){ size_t max_num =10; std::thread t1(print_even, max_num); std::thread t2(print_odd, max_num); t1.join(); t2.join();return0;}

需要注意的是,这里使用std::unique_lock 管理互斥锁,其析构函数会自动释放锁,避免了因异常路径导致的死锁问题。

第二种实现方案使用一个条件变量和一个布尔状态标志。当标志为true 时,允许偶数线程执行;为false 时,允许奇数线程执行。线程在打印并递增后,需要切换标志状态并通知等待的线程。执行逻辑如下:

偶数线程: 加锁 → 检查标志为true → 打印偶数 → 递增 → 标志置false → 通知 → 解锁 奇数线程: 加锁 → 检查标志为false → 打印奇数 → 递增 → 标志置true → 通知 → 解锁 

实现代码如下:

#include<iostream>#include<thread>#include<mutex>#include<condition_variable> std::mutex mutex; std::condition_variable cond;int counter =0;bool is_even_turn =true;// 标志:当前是否应打印偶数voidprint_even(size_t max_num){while(true){ std::unique_lock<std::mutex>lock(mutex);// 等待条件满足:当前应打印偶数while(!is_even_turn){ cond.wait(lock);}if(counter >= max_num){ is_even_turn =false; cond.notify_one();break;} std::cout <<"Even thread: "<< counter << std::endl;++counter; is_even_turn =false; cond.notify_one();}}voidprint_odd(size_t max_num){while(true){ std::unique_lock<std::mutex>lock(mutex);// 等待条件满足:当前应打印奇数while(is_even_turn){ cond.wait(lock);}if(counter >= max_num){ is_even_turn =true; cond.notify_one();break;} std::cout <<"Odd thread: "<< counter << std::endl;++counter; is_even_turn =true; cond.notify_one();}}intmain(){ size_t max_num =10; std::thread t1(print_even, max_num); std::thread t2(print_odd, max_num); t1.join(); t2.join();return0;}

以上两种方案均能正确实现交替打印的需求。第一种方案逻辑直接,易于理解;第二种方案通过状态标志减少了条件变量的数量,代码更为简洁。在实际应用中,可根据具体场景选择合适的实现方式。

atomic

我们知道,当多个线程并发访问同一个共享资源,且访问操作不具备原子性时,就必须通过互斥机制来保证线程安全。为了实现线程间的互斥访问,通常需要为临界区加互斥锁。考虑以下并发访问场景:

#include<iostream>#include<thread>#include<mutex> std::mutex _mutex;int count =0;voidadd1(size_t num){for(int i =0; i < num; i++){ std::unique_lock<std::mutex>lock(_mutex); count++;}}voidadd2(size_t num){for(int i =0; i < num; i++){ std::unique_lock<std::mutex>lock(_mutex); count++;}}intmain(){ size_t num =10000; std::thread thread1(add1, num); std::thread thread2(add2, num); thread1.join(); thread2.join();return0;}

在这个场景中,两个线程都会对共享变量 count 进行递增操作。为避免数据竞争,两个线程需要竞争同一把互斥锁,以确保对 count 的递增是原子的。虽然代码逻辑正确,但存在一个潜在问题:如果某个线程成功获取锁,而临界区代码执行时间很短,那么在该线程的时间片未耗尽的情况下,它会频繁地进行加锁与解锁操作。这会导致另一个线程被反复无效唤醒,从而引起大量的上下文切换,降低系统效率。

针对这种情况,有两种常见的优化策略。一种是在锁持有时间较短的场景下使用自旋锁:当锁被释放时,正在自旋的线程可以立即获取锁,避免了线程挂起和唤醒带来的上下文切换开销。

另一种更推荐的做法是将递增操作原子化。C++ 提供了 atomic 类模板来实现这一目的。当多个线程并发访问共享资源时,若操作不具备原子性,就可能引发数据不一致问题。理论上,只要将共享资源的访问操作原子化,即可避免这类问题。

在这里插入图片描述

那么要理解 atomic 的实现原理,首先需要补充一些计算机组成原理的相关知识。

我们知道,导致数据不一致的原因在于对共享资源的访问操作不具备原子性。以上文提到的场景为例,多个线程对共享资源进行递增操作,而递增操作之所以不具原子性,是因为其在编译后会映射为三条基本指令:从内存读取数据到寄存器、执行算术运算、将结果写回内存。如果多个线程并发执行该操作,假设它们都执行了前两条指令后被切换,那么当这些线程依次执行写回内存的指令时,各个线程在重新被 CPU 调度时,其寄存器中保存的值可能已经过时,从而导致前一个线程的写入结果被后一个线程覆盖。

因此,当多个线程执行递增操作时,该操作会存在“执行中”的状态。也就是说,线程在执行递增对应的基本指令时可能发生切换。而我们知道,CPU 执行单条基本指令是具备原子性的。所谓原子性,指的是指令只存在“已执行”和“未执行”两种状态,不存在“执行中”的状态。CPU 在执行每一条指令期间是不可中断的,不会响应任何硬件中断。只有在指令执行结束或开始之前,CPU 才能响应中断。

需要注意的是,造成数据不一致性 的核心原因并非访问操作映射为多条基本指令本身,而是因为这些指令中包含了内存的读和写指令。如果内存的读取和写入操作不能连续完成,中间可能被中断,就会引发数据不一致性 问题。因此,一种解决思路是:如果能够将内存的读和写操作合并为一条原子指令,使其能连续完成,即可避免数据不一致。

我们知道,CPU 需要与底层硬件(如各类 I/O 设备和内存)进行交互,这就涉及数据的传输。CPU 与这些硬件之间通过总线相连,数据通过总线进行传输。

总线可分为三类:地址总线、数据总线和控制总线。地址总线用于传输物理地址。当 CPU 访问内存时,需通过地址总线将目标物理地址发送给内存。

在这里插入图片描述

需要注意的是,计算机底层有多个 I/O 设备,每个设备在某一时刻都可能向 CPU 发出数据传输请求,但它们与 CPU 之间共享同一根数据总线。这种情况类似于多线程并发访问共享资源,不过是发生在硬件层面。由于所有 I/O 设备(包括内存)共享数据总线,同一时刻只能有一个设备通过数据总线传输数据。如果有多个设备同时向总线发送数据,必然会导致数据冲突。因此,每个设备在使用数据总线前必须“锁定”总线。锁定方式是通过控制总线向总线仲裁器发送一个控制信号。总线仲裁器会接收多个设备的控制信号,并通常 按照“先到先得”的原则分配总线的独占权(取决于具体的调度策略),即控制信号最先到达仲裁器的设备获得总线使用权。

了解设备传输数据前需先锁定总线的机制后,我们来看现代多核 CPU 的情况。每个 CPU 核心均可运行独立的线程上下文,因此多核 CPU 能够实现真正的并发执行。在某一时刻,可能存在多个核心同时发起内存访问请求。由于地址总线只有一根,且为所有核心所共享,这些核心必须竞争地址总线的独占权。竞争方式仍是通过控制总线向仲裁器发送控制信号,最先到达的信号对应的核心将锁定地址总线,其他核心则必须等待该核心完成地址传输后才能依次获取总线使用权。

在 x86 架构下,CPU 支持一些原子操作,如递增、递减和位运算等。所谓原子操作,是指这些高级操作在编译后只对应一条基本指令。由于单条指令具备原子性,因此能保证这些操作不会出现数据不一致问题。这些指令通常带有lock 前缀,其原理是让对内存的读和写操作连续执行,中途不被中断。

部分读者可能对“架构”这一术语感到陌生。架构可理解为 CPU 设计的蓝图,定义了 CPU 所能理解和执行的指令集合(即指令集)、寄存器结构、内存访问方式等。目前 Intel/AMD 公司生产的 CPU 主要采用 x86 架构,其支持的指令集为 CISC(复杂指令集)。CISC 的特点是指令长度可变,且一条指令可能包含多个底层操作(如同时涉及读内存和写内存)。与之相对的是 RISC(精简指令集),其指令长度固定,一条指令通常只对应一个基本操作。

带有lock 前缀的原子指令在执行时,由于必然涉及内存访问,CPU 会通过控制总线发送一个带有 lock 标识的控制信号。即使该信号之前已有其他信号到达总线仲裁器,那么一旦仲裁器响应了 lock 的信号,那么该核心会一直锁定总线的控制权,再此期间,仲裁器不会响应其他信号的请求,从而能够连续完成内存的读取和写入。

// x86架构的原子指令示例// 原子加法 lock add dword ptr [rax],1 lock add dword ptr [mem], imm32 ; 原子加法 lock sub dword ptr [mem], imm32 ; 原子减法 lock and dword ptr [mem], imm32 ; 原子与 lock or dword ptr [mem], imm32 ; 原子或 lock xor dword ptr [mem], imm32 ; 原子异或 lock xadd dword ptr [mem], reg ; 原子交换并加 lock cmpxchg dword ptr [mem], reg ; 原子比较并交换 xchg reg, dword ptr [mem]; 原子交换(隐含lock) 

但在现代 CPU 中,lock 前缀通常不再优先锁定总线,而是优先锁定对应的缓存行。这是因为每个 CPU 核心都有自己的一块缓存,其访问速度远高于内存。根据局部性原理,CPU 访问内存时不仅会加载目标数据,还会将其相邻数据(通常为一个缓存行的大小,一般为 64 字节)加载到缓存中。在访问内存前,CPU 会先检查缓存是否已有所需数据;如果未命中,才会按前述流程竞争总线。

由于每个核心均有缓存,大多数情况下 CPU 会直接访问缓存而非内存,这就引入了内存可见性问题。在多线程并发访问共享资源时,该资源很可能已被加载到多个核心的缓存中。此时,若某个线程竞争到锁并修改了共享资源(包括更新缓存并将结果写回内存),而其他核心的缓存中仍是旧值,那么当其他线程随后访问该资源时,由于缓存未更新,仍会读取到过期数据,从而导致数据不一致。

因此,现代 CPU 会实现一套缓存一致性协议,即 MESI 协议。所谓缓存一致性协议,是指每个缓存行(Cache Line)都会对应一个状态标记。在 MESI 协议中,状态主要分为四种: E (独占, Exclusive )、 M (修改, Modified )、 I (无效, Invalid )和 S (共享, Shared )。

独占( E )状态表示当前缓存行中的数据与主内存中的数据一致,并且其他核心没有该数据的副本,仅当前核心持有该缓存行。无效( I )状态表示该缓存行已过时(例如因其他核心修改了对应数据),或者该缓存行尚未加载有效数据。共享( S )状态表示多个核心共同持有同一缓存行的副本,且数据一致。修改( M)状态则表示该缓存行已被当前核心修改,与内存中数据不一致,且只有当前核心持有该缓存行,其他核心均无副本。

在执行带有 lock 前缀的指令进行内存写操作时,会触发缓存一致性协议的执行。其基本原理是:首先检查当前核心中目标缓存行的状态。若状态为 EM ,说明仅有当前核心持有该缓存行,因此可直接修改缓存中的数据,并将状态置为 M (若原状态为 E )。若当前缓存行状态为 S ,说明多个核心均持有最新数据副本,此时需向其他核心发出信号,使其将对应缓存行状态无效化(即从 S 改为 I ),之后当前核心再修改自身缓存行数据,并将状态改为 M 。若缓存行状态为 I ,表明数据已失效或未加载,此时不区分具体情形,处理流程一致:先检查其他核心是否持有有效缓存行(即状态为 EM )。若有,则直接从该核心缓存行读取数据,无需访问内存;若其他核心中存在状态为 S 的缓存行,则读取其中任意一个副本到本地,接着使其他所有 S 状态的缓存行无效化(改为 I ),再修改数据并将当前状态设为 M ;若所有核心中该缓存行状态均为 I ,则需通过总线锁定机制,访问内存加载数据至缓存行,并将其状态设为 E

同样, lock指令进行内存读操作也会触发缓存一致性协议。具体而言,若当前核心缓存行状态为 EMS ,则可直接从本地缓存读取。若状态为 I ,则先查询其他核心:如有核心缓存行状态为 E,则读取其数据,并将双方状态均改为 S ;如有核心状态为 M ,则先将该脏数据写回内存,再将所有相关缓存行状态设为 S ;如有核心状态为 S ,则直接读取其数据。若所有核心中该缓存行均为 I ,则需锁定总线、访问内存,将数据加载至缓存行后设为 E 状态。

掌握了缓存一致性协议之后,我们便可以进一步理解 atomic的底层实现原理。我们知道,CPU 硬件层面提供了一些原子指令,支持诸如递增、递减、算术运算和逻辑运算等操作。atomic 是一个模板类,它通过模板特化的方式为多种内置类型(如intbool 等)提供了对应的原子版本。在这些特化类中,递增和递减等运算符被重载,其底层实现通常使用 lock前缀的指令,确保内存的读写操作连续且不可分割,并借助缓存一致性协议和总线锁定机制来保证多核环境下的内存可见性,从而避免数据不一致的问题。

在这里插入图片描述

如果直接对普通内置类型(如int )进行递增或算术运算,编译后通常会对应多条机器指令,这些指令在执行过程中可能被中断,无法保证原子性。而使用atomic 类型进行相同操作时,实际上调用的是对应特化类中重载的运算符(如 operator++)或成员函数(如 fetch_add)。这些函数内部通过lock 类指令将操作映射为 CPU 支持的原子指令。

需要注意的是,原子指令的支持程度与操作对象的类型有关。对于内置类型,通常可以映射到单条原子指令;但对于自定义类型(如包含年、月、日三个字段的Date 类),由于涉及多个内存区域的修改,CPU 无法通过一条指令实现原子操作。在这种情况下,atomic类会采用备用方案——使用互斥锁来模拟原子行为。我们可以通过is_lock_free() 函数判断当前原子对象是否真正无锁。因此,对于自定义类型的“原子”操作,本质上是一种基于锁的原子性模拟。

那么,在实际开发中应如何选择atomic 还是互斥锁呢?这取决于共享资源的类型和操作方式。如果资源是内置类型,且操作(如递增、算术运算)有对应的原子指令支持,则优先使用atomic 类;如果资源是自定义类型,或操作较为复杂(特别是需要同步机制),则可能仍需使用互斥锁。简言之,atomic 适用于可映射到硬件原子指令的简单场景,而复杂或非内置类型的同步仍需依赖锁机制。

了解了atomic的原理后,我们可以将其应用于上文提到的场景。假设此处我们不使用任何互斥机制,则可以明显观察到数据不一致的问题:

#include<iostream>#include<thread>#include<atomic>#include<mutex>int var =0;voidprint1(size_t num){int i =0;while(i<num){ var++; i++;}}voidprint2(size_t num){int i =0;while(i<num){ var++; i++;}}intmain(){int num =100000; std::thread thread1(print1, num); std::thread thread2(print2, num); thread1.join(); thread2.join(); std::cout <<"num :"<< num << std::endl;return0;}
在这里插入图片描述

由于本例中的共享资源为内置类型,且仅进行简单的递增操作,不涉及复杂的同步机制,因此可以将递增操作原子化,无需使用互斥锁:

#include<iostream>#include<thread>#include<atomic>#include<mutex> std::atomic<int> var =0;voidprint1(size_t num){int i =0;while(i<num){ var++; i++;}}voidprint2(size_t num){int i =0;while(i<num){ var++; i++;}}intmain(){int num =100000; std::thread thread1(print1, num); std::thread thread2(print2, num); thread1.join(); thread2.join(); std::cout <<"var :"<< var << std::endl;return0;}
在这里插入图片描述

在此场景下,使用原子操作能更有效地保证数据一致性。需要补充说明的是,atomic类提供了 loadstore 成员函数。其中, load 用于读取并返回当前存储的值, store则用于原子性地更新存储值。

实际上,store 函数和load 函数在底层实现上直接对应一条原子指令,甚至无需使用lock 前缀。这是因为像简单的赋值和存储操作,在硬件层面通常只需一条mov 指令即可完成,并且这类操作本身在特定条件下是天然原子的。那么,既然基础的读取和写入操作已具备原子性,为何还需要专门的 storeload 函数呢?这就涉及到指令重排(Instruction Reordering)的问题。

可能部分读者对指令重排(Instruction Reordering)概念尚不熟悉。简单来说,指令重排是指编译器或CPU为了优化性能,可能会打乱代码的执行顺序,但保证最终结果与顺序执行一致。例如以下代码:

intmain(){int a=1;int b=4;int c=a+b;return0;}

从代码逻辑看,执行顺序应为 a=1b=4c=a+b 。但实际执行时,CPU可能先执行 b=4 ,再执行 a=1 ,最后计算 c 。由于操作独立性,重排后结果依然正确。这种优化对上层不透明,且单线程下无法感知。

然而在多线程环境下,指令重排可能导致意外后果。考虑以下场景:

bool flag=false;int data=0;voidthreadfun1(){ data+=10; flag=true; std::this_thread::sleep_for(std::chrono::seconds(10));}voidthreadfun2(){while(!flag); std::cout<<"data :"<<data<<std::endl;}

线程threadfun2 试图打印 threadfun1 修改后的 data 值。在单线程模型中, flagdata 修改后被置为 true ,逻辑正确。但若发生指令重排, threadfun1 中可能先执行 flag=true ,再执行 data+=10 。这将导致 threadfun2data 未完成修改时便跳出循环,读取到错误的数据值。

原子操作的 loadstore 函数通过引入内存屏障(Memory Barrier)防止此类重排,确保多线程环境下的语义正确性。因此,在编写无锁并发程序时,应优先使用原子操作而非直接操作共享变量。

而 atomic 中的 load 和 store 函数的主要作用,正是为了在必要时对指令执行顺序施加约束,防止编译器或处理器进行可能影响程序正确性的重排。因此, load 和 store 函数通常会接受一个 mem_order 参数,该参数为编译时常量,用于指定所需的内存顺序语义。在大多数情况下,我们可以直接使用其默认参数,即顺序一致性( memory_order_seq_cst ),它能够提供最严格的内存顺序保证,从而确保指令的执行顺序与代码书写顺序一致。

namespace std {typedefenummemory_order{ memory_order_relaxed,// 宽松顺序 memory_order_consume,// 消费顺序 memory_order_acquire,// 获取顺序 memory_order_release,// 释放顺序 memory_order_acq_rel,// 获取-释放顺序 memory_order_seq_cst // 顺序一致性(默认)} memory_order;}

最后,我们来介绍CAS(Compare-And-Swap)操作。CAS 是一种用于保证原子性的常见机制。那么,什么是 CAS?

CAS 可以理解为一种包含两个步骤的原子操作:比较(Compare)和设置(Set)。正如其缩写所示,CAS 能够以原子方式完成比较和写入操作,整个过程不会被中断。我们仍以多线程并发访问整型共享资源并执行递增操作为例。递增操作本身不具备原子性,会导致数据不一致问题,其根本原因在于内存的读取和写入操作不是连续完成的,中间可能被中断。

第一种解决方案,如之前所述,是确保内存的读取和写入连续完成,即通过底层的一条原子指令(如 lock add )来实现。

第二种方案则是使用 CAS。其设计思想不同于第一种严格保证原子性的方式,而是允许读取和写入操作不连续,中间可以被调度切换。但在执行最后的写入操作之前,会进行一次检查:比较当前内存中的值与一个期望值(即之前从内存中读取的值),同时提供一个新值作为写入目标。如果内存中的当前值与期望值相等,说明从读取到写入的期间没有其他线程修改该内存位置,此时将新值写入内存并返回成功;否则,说明在此期间内存已被其他线程修改,当前写入操作会失败并返回错误,从而避免覆盖其他线程的修改结果。

// 伪代码表示CAS操作boolCAS(内存地址, 期望值, 新值){if(*内存地址 == 期望值){*内存地址 = 新值;returntrue;// 操作成功}else{returnfalse;// 操作失败}}// 整个CAS操作是原子的,不会被中断

需要注意的是,现代处理器通常提供底层的 CAS 硬件指令。在 C++ 中, std::atomic 类提供了compare_exchange_weak 成员函数,该函数接收期望值和新值作为参数。由于是成员函数,它会隐式传递
this 指针,因此当前对象的内存地址将作为写入目标。

compare_exchange_weak 函数底层直接调用硬件 CAS 指令,并通过检查处理器标志位来判断操作是否成功。然而,如果要修改的对象大小超过机器字长(例如自定义类型),则无法直接通过单条指令实现原子操作。此时,compare_exchange_weak 可能会通过加锁方式来模拟 CAS 行为,以保证操作的原子性。

CAS 的典型应用之一是自旋锁(spinlock)的实现。自旋锁的基本原理是不断检查锁的状态,而锁状态本质上是一个整型变量。通常,当该变量的值为 0 时表示锁处于未锁定状态,值为 1 时表示锁已被占用。在使用 CAS 操作时,我们期望的旧值(expected value)为 0,表示当前锁未被持有;而目标内存地址即为锁状态变量所在的内存地址。CAS 会检查该内存中的值是否为 0:如果不是,则返回 false,此时线程将继续自旋等待;若为 0,则成功将新值 1 写入内存,表示当前线程已获得锁。

除了自旋锁,CAS 还可用于实现无锁(lock-free)编程。在多线程并发访问共享资源时,若该访问操作不具备原子性,常见的同步策略包括加锁机制,另一种思路则是尝试无锁编程。以单链表的插入操作为例:假设存在一个由头指针和尾指针维护的单链表,其中尾指针始终指向链表的最后一个节点。现有一个 insert 函数用于在链表尾部插入新节点,其基本步骤包括:创建新节点,通过尾指针定位当前尾节点,将尾节点的 next 指针指向新节点,最后更新尾指针指向新节点。显然,这一系列操作并非原子操作,因此多线程并发调用 insert 函数时需进行同步。传统方法是使用锁,而另一种思路则是基于 CAS 实现无锁插入。

在创建新节点、修改尾节点的 next 指针以及更新尾指针的过程中,可能会发生线程切换,导致数据竞争。为此,我们需要引入第一次 CAS 检查:以尾节点的 next 指针作为操作对象,期望其值为 nullptr (即当前尾节点无后继),目标值为新节点的地址。若当前尾节点的 nextnullptr ,则 CAS 操作成功,将新节点链接至链表尾部;否则,表示其他线程已修改了尾节点,本次操作失败。成功链接新节点后,还需执行第二次 CAS 操作以更新尾指针,确保其指向新的尾节点。

以下为无锁链表插入的示例代码:

structNode{int data; std::atomic<Node*> next;// 原子指针Node(int val):data(val),next(nullptr){}};classLockFreeLinkedList{private: std::atomic<Node*> head{nullptr}; std::atomic<Node*> tail{nullptr};public:voidpush_back(int value);};voidLockFreeLinkedList::push_back(int value){ Node* new_node =newNode(value);while(true){ Node* old_tail = tail.load();// 读取当前尾节点// 第一次CAS检查:确认尾节点的next指针是否为nullptrif(old_tail->next.compare_exchange_weak(nullptr,// 期望值:next应为nullptr(表示当前为尾节点) new_node // 新值:将其设置为新节点)){// CAS操作成功,已将新节点链接至链表尾部// 第二次CAS检查:更新尾指针指向新节点 tail.compare_exchange_weak(old_tail, new_node);break;}else{// CAS操作失败,说明其他线程已修改尾节点的next指针// 协助完成尾指针更新,并重试当前操作 tail.compare_exchange_weak(old_tail, old_tail->next.load());}}}

智能指针的线程安全

智能指针基于RAII(Resource Acquisition Is Initialization)思想,将关联资源的生命周期委托给对象管理。即构造函数接管资源,析构函数释放资源,对于shared_ptr ,其允许多个智能指针对象共享同一资源。为确保析构函数正确释放资源,shared_ptr 会维护一个引用计数,确保资源仅由最后一个关联该资源的智能指针对象释放(即引用计数降为0时)。当 shared_ptr 需要解除当前关联的对象并绑定到新对象时(即调用赋值运算符重载函数),首先会对引用计数执行递减操作。若此时引用计数为0,则表明当前对象是最后一个关联该资源的对象,需释放资源。

然而,在多线程环境下,若多个线程并发访问同一 shared_ptr 对象(例如执行赋值操作),其引用计数的递减操作并非原子性,可能引发数据竞争问题。由于引用计数通常为内置整数类型,且对其访问的操作(如递减)是简单的非同步操作,缺乏线程安全保证。为解决此问题,可将引用计数实现为堆上分配的原子对象,从而确保其操作的原子性与线程安全。

// 不安全的操作!! std::shared_ptr<MyObject> global_ptr = std::make_shared<MyObject>();voidthread_func(){// 多个线程同时读写同一个 global_ptr 对象 global_ptr = std::make_shared<MyObject>();// 赋值操作非原子,会导致数据竞争!}intmain(){ std::thread t1(thread_func); std::thread t2(thread_func);// 未定义行为! t1.join(); t2.join();}
// 示例:自定义线程安全智能指针的简化设计classThreadSafeSharedPtr{public:// 构造函数、析构函数及其他接口需实现原子操作// ...private: T* _ptr; std::atomic<int>* count;// 使用原子类型包装引用计数// ...};

需注意的是,C++标准库实现的 std::shared_ptr 已通过原子操作保证引用计数的修改是线程安全的,但其指向的对象本身的并发访问仍需用户自行管理同步。

结语

那么这就是本文全部的内容,本文是c++高阶系列的第一篇文章,那么很感谢耐心看到这里的读者,那么后序我会更新c++14以及c++17,为大家介绍更多的新语法和新特性,本博客制作不易,我会持续更新,希望你能够多多关注,如果本文有帮助到你,还请三连加关注,你的支持就是我创作的最大动力!

在这里插入图片描述

Read more

基于腾讯云HAI + DeepSeek快速设计自己的个人网页

基于腾讯云HAI + DeepSeek快速设计自己的个人网页

前言:通过结合腾讯云HAI 强大的云端运算能力与DeepSeek先进的 AI技术,本文介绍高效、便捷且低成本的设计一个自己的个人网页。你将了解到如何轻松绕过常见的技术阻碍,在腾讯云HAI平台上快速部署DeepSeek模型,仅需简单几步,就能获取一个包含个人简介、技能特长、项目经历及联系方式等核心板块的响应式网页。 目录 一、DeepSeek模型部署在腾讯云HAI 二、设计个人网页 一、DeepSeek模型部署在腾讯云HAI 把 DeepSeek 模型部署于腾讯云 HAI,用户便能避开官网访问限制,直接依托腾讯云 HAI 的超强算力运行 DeepSeek-R1 等模型。这一举措不仅降低了技术门槛,还缩短了部署时间,削减了成本。尤为关键的是,凭借 HAI 平台灵活且可扩展的特性,用户能够依据自身特定需求定制专属解决方案,进而更出色地适配特定业务场景,满足各类技术要求 。 点击访问腾讯云HAI控制台地址: 算力管理 - 高性能应用服务 - 控制台 腾讯云高性能应用服务HAI已支持DeepSeek-R1模型预装环境和CPU算力,只需简单的几步就能调用DeepSeek - R1

By Ne0inhk
AI革命先锋:DeepSeek与蓝耘通义万相2.1的无缝融合引领行业智能化变革

AI革命先锋:DeepSeek与蓝耘通义万相2.1的无缝融合引领行业智能化变革

云边有个稻草人-ZEEKLOG博客 目录 引言 一、什么是DeepSeek? 1.1 DeepSeek平台概述 1.2 DeepSeek的核心功能与技术 二、蓝耘通义万相2.1概述 2.1 蓝耘科技简介 2.2 蓝耘通义万相2.1的功能与优势 1. 全链条智能化解决方案 2. 强大的数据处理能力 3. 高效的模型训练与优化 4. 自动化推理与部署 5. 行业专用解决方案 三、蓝耘通义万相2.1与DeepSeek的对比分析 3.1 核心区别 3.2 结合使用的优势 四、蓝耘注册流程 五、DeepSeek与蓝耘通义万相2.1的集成应用 5.1 集成应用场景 1. 智能医疗诊断

By Ne0inhk
如何通过 3 个简单步骤在 Windows 上本地运行 DeepSeek

如何通过 3 个简单步骤在 Windows 上本地运行 DeepSeek

它是免费的——社区驱动的人工智能💪。         当 OpenAI 第一次推出定制 GPT 时,我就明白会有越来越多的人为人工智能做出贡献,并且迟早它会完全由社区驱动。         但从来没有想过它会如此接近😂让我们看看如何在 Windows 机器上完全免费使用第一个开源推理模型!  步骤 0:安装 Docker 桌面         我确信很多人已经安装了它,所以可以跳过,但如果没有 — — 这很简单,只需访问Docker 的官方网站,下载并运行安装 👍         如果您需要一些特定的设置,例如使用 WSL,那么有很多指导视频,请查看!我将继续下一步。 步骤 1:安装 CUDA 以获得 GPU 支持         如果您想使用 Nvidia 显卡运行 LLM,则必须安装 CUDA 驱动程序。(嗯……是的,它们需要大量的计算能力)         打开CUDA 下载页面,

By Ne0inhk
在 VSCode 中本地运行 DeepSeek,打造强大的私人 AI

在 VSCode 中本地运行 DeepSeek,打造强大的私人 AI

本文将分步向您展示如何在本地安装和运行 DeepSeek、使用 CodeGPT 对其进行配置以及开始利用 AI 来增强您的软件开发工作流程,所有这些都无需依赖基于云的服务。  步骤 1:在 VSCode 中安装 Ollama 和 CodeGPT         要在本地运行 DeepSeek,我们首先需要安装Ollama,它允许我们在我们的机器上运行 LLM,以及CodeGPT,它是集成这些模型以提供编码辅助的 VSCode 扩展。 安装 Ollama Ollama 是一个轻量级平台,可以轻松运行本地 LLM。 下载Ollama 访问官方网站:https://ollama.com * 下载适合您的操作系统(Windows、macOS 或 Linux)的安装程序。 * 验证安装 安装后,打开终端并运行: ollama --version  如果 Ollama 安装正确,

By Ne0inhk