C# 进行AI工程开发-基础篇

大局观

一直以来,官方口径都是尽量不要碰 CSharp 里的 unsafe 部分,以至于在大部分其它语言的程序员眼里,甚至 CSharp 程序员的眼里,CSharp 就是一个 java,做做 CRUD,捣鼓捣鼓局限于 windows 平台的 Winform 和 WPF 就行了。

我觉得这种观念是不对的,东西做出来就是让人用的。准确看待一件事情,需要有一个大局观和整体观,而大局观和整体观,就避免不了去触碰 CSharp 里的 unsafe 部分。必须打开 unsafe,才能完整的理解 dotnet 和 csharp。.

这里讲讲我的理解:

1、两类类型

在应用层开发语言中,使用带 GC 的运行时,可以极大的提高开发的速度。带 GC 的这些运行时中,排除实验性质的,dotnet 是最先进的。

虽然dotnet/csharp的初衷是替代 java,但在设计原则上,选择了不同的路线。

dotnet 在设计之初,就把值类型给设计进来了,整个体系,拥有两大类类型系统:引用类型和值类型,这两类类型具备不同的处理机制。最近的版本更新,还在不断加强值类型这块的功能和使用便捷性。而在 java 中,只有少量的基础类型,无法自定义和扩展。这导致,在写很多类型程序时,用 java 来写,很别扭。

这种设计的优点,csharp 特别擅长进行一些类型的程序开发,比如,游戏开发以及非结构化数据的处理开发。这两类开发中,需要大量的自定义值类型,否则开发体验和运行体验就要大打折扣。

2、三类内存

csharp 三类内存均是可友好操作的:托管堆、非托管堆和栈。引用类型一般分配在托管堆上,值类型可以在三个地方飘。

这有下面的好处:

可以进行精细的内存管理,性能优化和内存优化的手段非常多;

可以很方便的设计二进制接口,与其它语言交互。因为这一特点,在 NativeAOT 成熟后,在非实时场景下,会有很多公司选择用 csharp 来开发二进制SDK或基础设施,提供给其他语言来使用。

3、基础设施与语法糖,方便开发:

通过 GC 来管理托管资源。

提供了 dispose 模式,用来管理非托管资源。

提供了 using 语法糖,简化对 disposable 对象的使用。

提供了 span,可以统一的对栈内存、非托管堆和托管堆进行操作。

4、unsafe 很安全

大家很诟病 unsafe 的一点就是,unsafe 不安全,经常说:既然用 csharp 了,干嘛用这些?

使用 unsafe 的场景,比较的对象就不是托管开发了,而是 cpp 和 rust 这些。和它们相比,unsafe 安全得多,使用好 dispose 模式和 using 语法糖,出错的概率很小。即使在以前没有 span 的时候,我狂用指针,出错的概率大约是两、三个月一起。即使出了错,查找的范围也很少,很快就找到问题了。

可 csharp 的编译速度、工具体系和生态,相比 cpp 和 rust,要优秀得多。

干嘛不用!

整体的看,csharp 在我眼中,就不是一个和 java 对标的语言,而是,带 GC 的,延续 c++ 发展路线的,下一代开发语言,这也是 csharp 命名的本意:c++++。

整个基础篇里,就是从这个角度来看 csharp。

这一节里,从内存管理的角度切入,来讲讲 csharp 给我们提供了哪些工具和基础设施。

三大内存区域

csharp 里有三大内存区域:托管堆内存;非托管堆内存;栈内存。

托管堆内存:由 GC 管理的内存。new 一个 class,class 的本体就在托管堆上,交给 GC 来管理。

非托管堆内存:可以通过 Marshal.AllocHGlobal 和 Marshal.FreeHGlobal 方法来分配和释放内存,这里得到的内存是非托管堆内存,GC 管不着,自己进行管理;

栈内存:可以进行栈上进行一些内存操作。

示例代码:

// sample1.csx

using System.Runtime.InteropServices;

struct BGR
{
    public Byte B,G,R;
}

class BGRClass
{
    public Byte B,G,R;
}

unsafe void Test()
{
    // 栈上处理
    BGR c1 = new BGR();
    c1.R = 200;
    Console.WriteLine(c1.R);

    // 托管堆上处理
    BGRClass c2 = new BGRClass();
    c2.R = 200;
    Console.WriteLine(c2.R);

    // 非托管堆上处理
    IntPtr buff = Marshal.AllocHGlobal(sizeof(BGR));
    BGR* pBGR = (BGR*)buff;
    pBGR->R = 200;
    Console.WriteLine(pBGR->R);
    Marshal.FreeHGlobal(buff);
}

Test();

引用类型、托管值类型与非托管值类型

一般的文章会声称:"csharp 包含引用类型和值类型,引用类型分配在堆上,值类型分配在栈中……"这句话是错误的。准确的理解 csharp 里的类型系统,这么分类会更好:

引用类型

值类型

托管值类型

非托管值类型

1、引用类型和值类型最本质的区别是什么?

值类型具有值(复制)语义,它的本质就是一坨大小固定的内存,函数调用时可以传值,也可以传引用。引用类型没有值语义,函数调用时,只能传引用。

// sample2.csx

struct BookStruct
{
    public String Name = "Java 编程思想";
    public BookStruct(){}
}

void Test1(BookStruct book)
{
    book.Name = "C# in depth";
}

void Test2(ref BookStruct book)
{
    book.Name = "C# in depth";
}

BookStruct book = new BookStruct();
Console.WriteLine(book.Name); //Java 编程思想
Test1(book);
Console.WriteLine(book.Name); //Java 编程思想
Test2(ref book);
Console.WriteLine(book.Name); //C# in depth

运行结果:

Java 编程思想
Java 编程思想
C# in depth

为了更安全的编程,dotnet 给值类型和引用类型分别加了约束:

(a)值类型的约束:- 不能继承。继承会让值语义变得复杂,比如,子类型在父类型上加了点东西,以父类型传值的时候,加的这点东西就传不进去。- 不能单独存在于托管堆上,除非装箱或者放在引用类型的本体中。这一点也可以理解,它就是一坨内存,没有抓手,让 GC 管理,得有抓手,装箱,就是给它装一个抓手。

(b)引用类型的约束:- 必须是GC托管的。强制GC托管后,用户更省心。

这两个限制,也是 csharp 和 cpp 的不同之处。除此之外,cpp 能做的,csharp 都能做。加了这两个限制,能让写代码更安全。cpp 太奔放了 ......

很多文章会建议,64字节以上的不建议用 struct,复制成本太高,这纯属扯淡,大的值类型,传引用就行了嘛。不要理会这条建议。

值语义有下面好处:

(a)方便复制、序列化和反序列化。

a = b。直接就把 b 给复制一份为 a 了。

系列化和反系列化也非常方便。如果没有特别的引用,它本身就是内存直接映射,是二进制序列化的形态,压根不需要序列化和反序列化。

(b)没有 GC 压力。

大量使用值类型可以减轻GC压力。尤其是在处理海量同等粒度的数据时,比如,语音,图像,视频,动不动几十万、几百万、几千万、几亿相同大小的元素,天生适合值类型来表达。这种要是使用引用类型,那 GC 可不得亚历山大了。

2、托管类型和非托管类型的本质区别是什么

要明白托管类型和非托管类型的本质区别,只需要分辨托管值类型和非托管值类型的区别就行了。看代码:

// sample3.csx
using System.Runtime.InteropServices;

struct BGR
{
    public Byte B,G,R;
}

struct Book
{
    public String Name;
}

unsafe void TestSizeOf()
{
    Console.WriteLine(sizeof(BGR));
    // Console.WriteLine(sizeof(Book));  // 无法获取托管类型(“Book”)的地址和大小,或者声明指向它的指针
}

unsafe void TestPoint()
{
    BGR color;
    Book book;
    BGR* pBGR = (BGR*)&color;
    Console.WriteLine(pBGR->B);
    // Book* pBook = (Book*)&book; // 无法获取托管类型(“Book”)的地址和大小,或者声明指向它的指针
    // Console.WriteLine(pBook->Name); // 无法获取托管类型(“Book”)的地址和大小,或者声明指向它的指针
}

unsafe void TestAlloc()
{
    IntPtr buff = Marshal.AllocHGlobal(100);
    BGR* pBGR = (BGR*)buff;
    Console.WriteLine(pBGR->B);
    // Book* pBook = (Book*)(buff);  // 无法获取托管类型(“Book”)的地址和大小,或者声明指向它的指针
    // Console.WriteLine(pBook->Name);
    Marshal.FreeHGlobal(buff);
}

TestSizeOf();
TestPoint();
TestAlloc();

运行结果:

3
0
224

上例中,Book 是托管值类型,BGR 是非托管值类型。规则如下:

所有引用类型皆为托管类型。都受到 GC 管理嘛,理解 ......

所有持有托管类型的类型,均是托管类型。就好比美帝,只要产品中用到我的东西,都会受到限制。

其它类型为非托管类型。这个范围就很小了,只剩下不持有托管类型的值类型了。

对于托管类型,dotnet 加了下面的约束(编译会报错):- 为了安全起见,不能使用指针,sizeof 什么的也不能用;- 不能用来操作非托管堆内存。

下面列表总结下三类类型可以分配的内存空间(这里不考虑逃逸分析、栈上分配等jit优化策略,以及黑科技强制在栈上分配引用类型的搞法):

  托管堆 非托管堆
引用类型 可以 不可以 不可以
托管值类型 可以 可以 不可以
非托管值类型 可以 可以 可以

其中,引用类型受限最大,托管值类型其次,非托管值类型限制最低。非托管值类型可以作为托管堆和非托管堆之间的桥梁,只有它,两边都能跑。此外,还有一类类型,ref struct,只能在栈上活动。因此,更完善的表格如下:

  托管堆 非托管堆
引用类型 可以 不可以 不可以
托管值类型 可以 可以 不可以
非托管值类型 可以 可以 可以
ref 值类型 不可以 可以 不可以
非托管值类型

在一些场景中,非托管值类型就变得很重要了。要写轻GC的代码,甚至完全没有 GC 的代码,就需要使用大量的非托管值类型。

再比如,要写SDK,给其它语言使用。其它语言,有带 GC 的语言,有不带 GC 的语言,不能直接传递托管堆里的对象,这时提供的接口,就必须是非托管值类型的接口。

再比如,要调用 c/c++ 等底层库,也必须通过非托管值类型来交互。

所以,它不单是托管堆和非托管堆的桥梁,也是在不同语言中构建生态的桥梁。

没有 NativeAOT 之前,我们只能通过 p/invoke 白嫖 c/c++ 生态,有了 NativeAOT 之后,我们不光能白嫖 c/c++ 的生态,还可以开发 SDK,供其它语言直接来调用。

Dispose 模式和 using 语法糖

从上面的讨论可以看出,打开 unsafe,才可看到 csharp 的全貌:

csharp = 加了gc及运行时和类型约束的 c++

还加了很多语法糖 ……

比如,为了更安全的管理非托管资源,csharp 又提供了 Dispose 模式和 using 语法糖。

// sample4.csx

using System.Runtime.InteropServices;

class MemoryStorage : IDisposable
{
    private IntPtr _pointer;
    public int Size { get; }

    public IntPtr Data{ get => _pointer; }

    public unsafe Span<Byte> DataSpan { get =>new Span<byte>((void*)_pointer, Size); }

    public MemoryStorage(int size)
    {
        _pointer = Marshal.AllocHGlobal(size);
        GC.AddMemoryPressure(size);
        Size = size;
    }
    ~MemoryStorage()
    {
        Dispose();
    }

    public void Dispose()
    {
        if(_pointer != IntPtr.Zero){
            Marshal.FreeHGlobal(_pointer);
            GC.RemoveMemoryPressure(Size);
            _pointer = IntPtr.Zero;
        }
    }
}

void Test()
{
    using MemoryStorage m = new MemoryStorage(100);
    Console.WriteLine(m.Size);
    var span = m.DataSpan;
    span.Fill(0xFF);
    Console.WriteLine(span[0]);
}

Test();

(注:这是最简化的 Dispose 模式实现,官方推荐的方式更复杂一些)

using 是 csharp 对 disposable 对象提供的语法糖,使用完了,就直接释放了。

csharp 语言下的零成本抽象

通过类型约束,和语法糖,在 csharp 下进行 unsafe 编程,实际上非常的 safe。

如果只使用非托管值类型,那么整个编程,就是cpp和rust意义下的零成本抽象。这个零成本抽象拥有下面的能力:

命名空间

泛型类型和泛型方法

非托管值类型

simd

这是啥怪物呢?

比 C 强大,比 C++ 弱一点,变成 C+ 了。如果再有个好使的零成本抽象标准库,在很多不能用GC的场景,也能替代C,C++和RUST了。

只差一个零成本抽象标准库啊!!!

结论

csharp 包含了两部分:

C+:零成本抽象部分,等于更强大的 clang;

C++++:加了类型约束、GC及运行时的 C++。

这个语言还在快速演变,如果再有个好使的零成本抽象标准库,一个语言,上可以干 python,java,js,golang,下可以干 c,cpp,rust 了。这是我一直坚持用 csharp 做主力开发语言的原因。