C#知识学习-017(修饰符_6)
目录
1.extern
1.1 核心概念
1.2 用法
1.3 举例
1.3.1 示例 1
1.3.2 示例 2
2.volatile
2.1 为什么需要
2.2 作用
2.3 关键限制
2.4 替代项
1.extern
1.1 核心概念
简单来说,extern
关键字在 C# 中用于声明一个方法,但这个方法的实际代码(实现)并不在你的 C# 程序里。它存在于外部,通常是:
-
非托管代码编写的动态链接库 (DLL):比如用C++ 写的库(Windows 系统本身就有很多这样的 DLL,如
User32.dll
,Kernel32.dll
) -
其他程序集(较少见用法):用于处理同一个组件不同版本共存的问题
1.2 用法
最常见用途:调用非托管 DLL(平台调用 / PInvoke)
怎么用?
-
extern
声明: 在你的 C# 代码中,用extern
关键字声明那个外部函数 -
DllImport
特性: 在这个声明上面,加上[DllImport("DLL文件名.dll")]
这个特性。这个特性告诉 .NET 运行时:“下面声明的这个函数,它的代码实际在 ‘DLL文件名.dll’ 这个文件里,你去那里找它来执行。”
关键规则:
-
必须
static
: 你用extern
和DllImport
声明的方法必须是static
的。因为你不是在调用一个属于某个类实例的方法,而是直接调用 DLL 文件里的一个独立函数 -
不能
abstract
: 不能同时用extern
和abstract
修饰同一个方法。abstract
是说“这个方法在这个类里没实现,但我的子类会实现它”,而extern
是说“这个方法已经在外部实现了”。两者意思冲突
1.3 举例
1.3.1 示例 1
调用 Windows API (MessageBox
)
// 引入必要的命名空间
using System.Runtime.InteropServices;class ExternTest
{// 关键声明:// [DllImport("User32.dll")]: 说明函数在 User32.dll 里// CharSet=CharSet.Unicode: 指定字符串使用 Unicode 编码// public static extern int: 声明一个外部静态方法,返回 int// MessageBox(...): 函数名和参数列表(必须和 DLL 里的函数原型匹配)[DllImport("User32.dll", CharSet = CharSet.Unicode)]public static extern int MessageBox(IntPtr h, string m, string c, int type);static int Main(){string myString;Console.Write("Enter your message: ");myString = Console.ReadLine();// 调用外部函数// 输入一段文字,然后弹出一个消息框显示这段文字return MessageBox((IntPtr)0, myString, "My Message Box", 0);}
}
1.3.2 示例 2
调用自己写的 C++ DLL
-
创建新的C++项目,选择"动态链接库(DLL)"
-
添加头文件
SampleDLL.h
:// SampleDLL.h #pragma once#ifdef CDEMODLL1_EXPORTS #define SAMPLEDLL_API __declspec(dllexport) #else #define SAMPLEDLL_API __declspec(dllimport) #endif// 使用extern "C"防止名称修饰(Name Mangling) extern "C" {SAMPLEDLL_API int SampleMethod(int i); }
-
添加源文件
SampleDLL.cpp
:// SampleDLL.cpp #include "pch.h" #include "SampleDLL.h"// 函数实现 SAMPLEDLL_API int SampleMethod(int i) {return i * 10; }
-
C#程序调用C++ DLL
// cm.cs using System; using System.Runtime.InteropServices;public class MainClass {// 在DllImport中明确指定CallingConvention.Cdecl,C++默认使用Cdecl调用约定[DllImport("SampleDLL.dll", CallingConvention = CallingConvention.Cdecl)]public static extern int SampleMethod(int x);static void Main(){Console.WriteLine("SampleMethod(5) returns {0}.", SampleMethod(5));} }
2.volatile
先说明核心建议:尽量避免使用 volatile
2.1 为什么需要
想象多个线程同时在操作同一个变量。为了提高效率,编译器和CPU可能会做一些优化:
- 缓存值: 一个线程可能会将共享变量从主内存加载到其私有的CPU高速缓存中。后续的读写操作都直接针对该缓存进行,导致修改可能不会立即被同步回主内存,从而使其他线程无法及时看到最新值。
- 重排操作:只要在单个线程内结果看起来一样就行。比如,先写A再写B,可能被优化成先写B再写A。
补充:
指令重排序在单线程环境下没有区别,但在多线程环境下会导致严重问题。让我用例子解释:
- 单线程执行
int a = 0;
int b = 0;void SetValues()
{a = 1; // 写操作 Ab = 2; // 写操作 B
}void ReadValues()
{Console.WriteLine($"a = {a}, b = {b}"); // 总是输出 a=1, b=2
}
编译器/CPU 视角: 只要 SetValues()
执行结果在单线程看来是 a
最终为 1,b
最终为 2,那么先执行 b = 2
再执行 a = 1
是完全允许的(重排序)。因为 ReadValues()
读取时,这两个赋值肯定都完成了。
- 多线程执行
// 两个线程共享这些变量
int a = 0;
int b = 0;// 线程 1 执行
void Thread1()
{a = 1; // 写操作 Ab = 2; // 写操作 B (假设b是某种"完成标志")
}// 线程 2 执行
void Thread2()
{while (b != 2) ; // 等待 b 变成 2 (等待标志置位)Console.WriteLine($"a = {a}"); // 期望此时 a 应该是 1
}
编译器/CPU 的优化:可能会对线程 1 的指令进行重排序,先执行 b = 2
再执行 a = 1
灾难性后果:
-
线程 1 先设置
b = 2
。 -
线程 2 看到
b == 2
,认为数据a
准备好了,于是读取a
。 -
但此时线程 1 可能还没来得及执行
a = 1
或者a = 1
的写入还没从线程 1 的缓存刷新到主内存,线程 2 读到的a
还是旧值0
,输出a = 0
。这完全违背了你的意图和逻辑预期!
所以在多线程环境下,这些优化会出问题!
2.2 作用
给一个字段加volatile,
就是告诉编译器和运行时系统:
- 禁止编译器优化: 编译器不能把这个变量缓存到寄存器里,每次读取都必须去内存上拿最新的值(解决可见性问题)。
- 禁止指令重排(相对): 编译器/CPU 不能随意调换对这个变量的读写操作与其他内存操作的顺序(提供一定的内存屏障效果)(解决指令重排序问题)。这有助于保证:
-
在读取
volatile
变量之后的操作,能看到这个读取发生之前的所有写入(包括非volatile
的)。 -
在写入
volatile
变量之前的操作,在这个写入被其他线程看到之前,都已经完成了。
-
我还用上面的例子继续说明:
private volatile int b = 0;
-
对线程 1 :
volatile
写入 (b = 2
) 会成为一个释放屏障。意味着:-
在
b = 2
之前的所有写操作(包括a = 1
)必须在b = 2
操作完成之前(对其他线程可见之前)完成。 -
编译器/CPU 不能把
a = 1
重排序到b = 2
之后。
-
-
对线程 2 :
volatile
读取 (while (b != 2)
) 会成为一个获取屏障 。意味着:-
在
b != 2
之后的所有读操作(包括Console.WriteLine(a)
)必须在b != 2
操作完成之后(读取到最新值之后)才开始。 -
编译器/CPU 不能 把
Console.WriteLine(a)
重排序到b != 2
之前。
-
2.3 关键限制
- 不是原子性:
volatile
只保证单个读或写操作本身是原子的并且不会被重排。 不保证复合操作原子性,像i++
(读->改->写)这种多步操作,即使i
是volatile
,在多线程下也不安全。 - 不是万能同步:
volatile
不能防止竞态条件。它只是让读写操作更“及时”和“有序”一点,但复杂的逻辑还是需要真正的同步机制(如锁)。 - 顺序保证有限:明确指出:“不确保从所有执行线程整体来看时所有易失性写入操作均按执行顺序排序”。
- 意思是,线程1写A然后写B(都是volatile),线程2看到B被写了,不一定意味着线程2也能看到A已经被写了(虽然可能性很大,但不100%保证)。现代硬件(多处理器)的缓存一致性协议很复杂。
- 类型限制:只能用于引用类型、指针类型(在不安全的上下文中)、基础类型(如
int
,bool
)以及基于这些基础类型的枚举等。其他类型(包括double
和long
)无法标记volatile
,因为无法保证读取和写入这些类型的字段是原子的。
2.4 替代项
在绝大多数需要多线程同步的场景下,有更好、更安全的替代品(先了解即可):
Interlocked
类:提供原子操作,非常适合计数器 (i++
) 或简单的状态切换。通常比volatile
更快且语义更强。lock
语句 :提供互斥锁。用于保护一段代码(临界区),同一时间只允许一个线程执行。能提供最强的内存屏障和原子性保证。适用于复杂的操作或需要保护多个变量的情况。Volatile
类 :提供Volatile.Read()
和Volatile.Write()
方法。比volatile
更明确地指定内存屏障位置。- 高级同步原语:例如 ReaderWriterLockSlim, Semaphore或来自System.Collections.Concurrent并发集合。
那什么时候可能考虑 volatile
?
在非常少见的、极其简单的场景下,并且你完全理解其限制时:
-
发布初始化完成的引用: 在某些特定的双重检查锁定模式变体中(但现在更推荐用
Lazy<T>
或Volatile.Read
)。 -
简单的状态标志位: 例如一个线程设置
_shouldStop = true;
另一个线程循环检查while (!_shouldStop)
。这里只涉及一个简单的布尔值读写。volatile
确保工作线程能及时看到主线程设置的停止信号。
public class Worker
{private volatile bool _shouldStop; // 关键在这里public void DoWork(){bool work = false;while (!_shouldStop) // 工作线程循环检查这个标志{work = !work;}Console.WriteLine("Worker thread: terminating.");}public void RequestStop(){_shouldStop = true; // 主线程调用这个方法设置标志}
}
-
问题: 如果没有
volatile
,DoWork
可能会把_shouldStop
的值缓存到自己的寄存器或缓存里。即使主线程调用了RequestStop()
把内存中的_shouldStop
改成了true
,工作线程可能还在读自己缓存里的旧值false
,导致它无法停止。 -
volatile
的作用: 加了volatile
后,工作线程每次执行while (!_shouldStop)
时,都必须去主内存读取_shouldStop
的最新值。这样当主线程设置_shouldStop = true
后,工作线程就能及时看到并退出循环。
学到了这里,咱俩真棒,记得按时吃饭(拌饭总比困难多~)
【本篇结束,新的知识会不定时补充】
感谢你的阅读!如果内容有帮助,欢迎 点赞❤️ + 收藏⭐ + 关注 支持! 😊