前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >C# 进行AI工程开发-基础篇

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

作者头像
郑子铭
发布2023-08-30 08:35:11
4540
发布2023-08-30 08:35:11
举报
文章被收录于专栏:DotNet NB && CloudNative

大局观

一直以来,官方口径都是尽量不要碰 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 管不着,自己进行管理;

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

示例代码:

代码语言:javascript
复制
// 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、引用类型和值类型最本质的区别是什么?

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

代码语言:javascript
复制
// 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

运行结果:

代码语言:javascript
复制
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、托管类型和非托管类型的本质区别是什么

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

代码语言:javascript
复制
// 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();

运行结果:

代码语言:javascript
复制
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 语法糖。

代码语言:javascript
复制
// 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 做主力开发语言的原因。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2023-07-03,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 DotNet NB 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 引用类型、托管值类型与非托管值类型
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档