C#知识学习-017(修饰符_6)

目录

1.extern

[1.1 核心概念](#1.1 核心概念)

[1.2 用法](#1.2 用法)

[1.3 举例](#1.3 举例)

[​1.3.1 示例 1](#1.3.1 示例 1)

[1.3.2 示例 2](#1.3.2 示例 2)

2.volatile

[2.1 为什么需要](#2.1 为什么需要)

[2.2 作用​​](#2.2 作用)

[2.3 关键限制](#2.3 关键限制)

[2.4 替代项](#2.4 替代项)


1.extern

1.1 核心概念

简单来说,extern关键字在 C# 中用于​​声明一个方法,但这个方法的实际代码(实现)并不在你的 C# 程序里​​。它存在于外部,通常是:

  1. ​非托管代码编写的动态链接库 (DLL)​ ​:比如用C++ 写的库(Windows 系统本身就有很多这样的 DLL,如 User32.dll, Kernel32.dll

  2. ​其他程序集(较少见用法)​​:用于处理同一个组件不同版本共存的问题

1.2 用法

​最常见用途:调用非托管 DLL(平台调用 / PInvoke)​

​怎么用?​

  • extern声明:​ ​ 在你的 C# 代码中,用 extern关键字声明那个外部函数

  • DllImport特性:​ ​ 在这个声明上面,加上 [DllImport("DLL文件名.dll")]这个特性。这个特性告诉 .NET 运行时:"下面声明的这个函数,它的代码实际在 'DLL文件名.dll' 这个文件里,你去那里找它来执行。"

关键规则:​

  • ​必须 static ​ 你用 externDllImport声明的方法​​必须​ ​是 static的。因为你不是在调用一个属于某个类实例的方法,而是直接调用 DLL 文件里的一个独立函数

  • ​不能 abstract:​ ​ 不能同时用 externabstract修饰同一个方法。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; // 写操作 A
    b = 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; // 写操作 A
    b = 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++(读->改->写)这种多步操作,即使 ivolatile,在多线程下也不安全。
  • ​不是万能同步:volatile不能防止竞态条件。它只是让读写操作更"及时"和"有序"一点,但复杂的逻辑还是需要真正的同步机制(如锁)。
  • 顺序保证有限:明确指出:"不确保从所有执行线程整体来看时所有易失性写入操作均按执行顺序排序"。
    • 意思是,线程1写A然后写B(都是volatile),线程2看到B被写了,不一定意味着线程2也能看到A已经被写了(虽然可能性很大,但不100%保证)。现代硬件(多处理器)的缓存一致性协议很复杂。
  • 类型限制:只能用于引用类型、指针类型(在不安全的上下文中)、基础类型(如 int, bool)以及基于这些基础类型的枚举等。其他类型(包括 doublelong)无法标记 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; // 主线程调用这个方法设置标志
      }

    }

  • ​问题:​ ​ 如果没有 volatileDoWork可能会把 _shouldStop的值缓存到自己的寄存器或缓存里。即使主线程调用了 RequestStop()把内存中的 _shouldStop改成了 true,工作线程可能还在读自己缓存里的旧值 false,导致它无法停止。

  • volatile的作用:​ ​ 加了 volatile后,工作线程每次执行 while (!_shouldStop)时,都必须去主内存读取 _shouldStop的最新值。这样当主线程设置 _shouldStop = true后,工作线程就能及时看到并退出循环。

学到了这里,咱俩真棒,记得按时吃饭(拌饭总比困难多~)

【本篇结束,新的知识会不定时补充】

感谢你的阅读!如果内容有帮助,欢迎 ​​点赞❤️ + 收藏⭐ + 关注​​ 支持! 😊

相关推荐
早睡冠军候选人3 小时前
Ansible学习----Ansible Playbook
运维·服务器·学习·云原生·容器·ansible
VB.Net3 小时前
C#循序渐进
开发语言·c#
楼田莉子3 小时前
C++学习:C++11扩展:constexpr特性
开发语言·c++·学习
懒羊羊不懒@3 小时前
Java基础语法—最小单位、及注释
java·c语言·开发语言·数据结构·学习·算法
qq_398586543 小时前
Threejs入门学习笔记
javascript·笔记·学习
feifeigo1234 小时前
C# WinForms实现模拟叫号系统
c#
helloworddm5 小时前
Orleans 流系统握手机制时序图
后端·c#
菜鸟‍5 小时前
【论文学习】大语言模型(LLM)论文
论文阅读·人工智能·学习
William_cl5 小时前
【C# OOP 入门到精通】从基础概念到 MVC 实战(含 SOLID 原则与完整代码)
开发语言·c#·mvc