大局观
一直以来,官方口径都是尽量不要碰 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 做主力开发语言的原因。