.NET Core 使用 TCP Fast Open (Linux & Windows)

TFO 已经不是个新技术了,哪怕是计算机网络方面最守旧的 Windows 如今也支持了 TFO(虽然只支持客户端,而且疑似不符合规范并且很多 bug)。最近正好做项目时用到了 TFO,因此总结一下 .NET Core 使用 TFO 的方法。

服务端

目前只有 Linux 对 TFO 服务端支持较好,因此我们暂时也只用考虑 Linux。
服务端对应用程序而言支持起来也是最简单的,只需要调用一次setsockopt就可以启用。
可以写一个扩展方法

const int TCP_FASTOPEN_LINUX = 23;

[DllImport("libc.so.6", SetLastError = true)]
static extern unsafe int setsockopt(int sockfd, int level, int optname, void* optval, int optlen);

public static unsafe void EnableLinuxFastOpenServer(this Socket socket)
{
    int qlen = 5;
    int result = setsockopt(
        (int)socket.Handle,
        6 /* SOL_TCP */,
        TCP_FASTOPEN_LINUX,
        &qlen, sizeof(int));
    if (result == -1)
    {
        throw new SocketException(Marshal.GetLastWin32Error());
    }
}

对 Socket 调用即可,以 TcpListener 为例

var listener = new TcpListener(endpoint);
listener.Server.EnableLinuxFastOpenServer();
listener.Start();

调用前需要检查 /proc/sys/net/ipv4/tcp_fastopen 的值是否包含 0x2 这个flag。如果不包含则会抛异常。

客户端

Windows

Windows 的 TCP Fast Open 客户端实现是设置 Socket option 之后,调用 Windows Server 2003 / Windows Vista 加入的 ConnectEx,在 lpSendBuffer 中送入初始 buffer。

.NET 并没有任何文档说明如何调用 ConnectEx,不过阅读 CoreFX.NET Framework Reference Source 可以发现, Socket.ConnectAsync(SocketAsyncEventArgs)使用了 ConnectEx,并且将传入的SocketAsyncEventArgs的buffer传递给了ConnectEx。
这样一来我们可以编写一个简单的async扩展方法,实现 Windows 上的 TFO。

const int TCP_FASTOPEN_WINDOWS = 15;

public static void EnableWindowsFastOpenClient(this Socket socket)
{
    socket.SetSocketOption(
        SocketOptionLevel.Tcp,
        (SocketOptionName)TCP_FASTOPEN_WINDOWS,
        1);
}

public static Task ConnectAsync(this Socket socket, EndPoint remoteEndPoint, ArraySegment initialData)
{
    var tcs = new TaskCompletionSource<object>();
    var eventArgs = new SocketAsyncEventArgs();

    eventArgs.Completed += (s, e) =>
    {
        if (e.SocketError == SocketError.Success)
        {
            tcs.TrySetResult(null);
        }
        else
        {
            tcs.TrySetException(new SocketException((int)e.SocketError));
        }
    };

    eventArgs.RemoteEndPoint = remoteEndPoint;
    eventArgs.UserToken = tcs;
    eventArgs.SetBuffer(initialData.Array, initialData.Offset, initialData.Count);

    if (socket.ConnectAsync(eventArgs))
    {
        return tcs.Task;
    }
    else
    {
        return Task.CompletedTask;
    }
}

先对Socket调用EnableWindowsFastOpenClient,再调用ConnectAsync即可。如果使用的是低版本的Windows,或用户关闭了 TFO,则EnableWindowsFastOpenClient会抛出异常。

Linux

Linux 有两种 TFO API,一种是使用 sendto+MSG_FASTOPEN 建立连接,支持 3.7+的内核;另一种是使用 TCP_FASTOPEN_CONNECT,支持 4.11+的内核。

.NET 的 Socket 在 Linux 上是一层比较厚的封装,因为参数都是原封不动地从 Winsock 复制站贴,在 Linux 上需要进行转换,自然也不支持调用 sendto 传入 MSG_FASTOPEN;而直接把 fd 拿出来调用原生 sendto 也很麻烦,会破坏一些 managed Socket 的状态,比如私有的 _connected 等,为后续使用带来麻烦。类似的问题还有 macOS 的 TFO,需要使用 connectx() 建立连接,只能等 .NET Core 支持。

相比之下 TCP_FASTOPEN_CONNECT 则是对 Socket 调用者而言完全透明的,它的原理是调用 connect() 时不立刻发送 SYN,而是在第一次调用 send 时发送 SYN (TFO=C) 和 data,此时才真正建立连接。

因此需要做的事情跟 Server 端一样,使用以下扩展方法完成

const int TCP_FASTOPEN_CONNECT = 30;

public static unsafe void EnableLinuxFastOpenConnect(this Socket socket)
{
    int val = 1;
    int result = setsockopt(
        (int)socket.Handle,
        6 /*SOL_TCP*/,
        TCP_FASTOPEN_CONNECT,
        &val, sizeof(int));
    if (result == -1)
    {
        throw new SocketException(Marshal.GetLastWin32Error());
    }
}

使用EnableLinuxFastOpenConnect()之后照常使用 Socket API 即可。需要注意的是 Connect 之后一定要保证调用 SendAsync 至少一次,否则连接不会真正建立,此时调用 ReceiveAsync 是不会返回的。

调用 EnableLinuxFastOpenConnect() 之前需要检查 /proc/sys/net/ipv4/tcp_fastopen 的值是否包含 0x1 flag;除此之外还需要检查内核版本是否符合要求(>4.11),可以通过Environment.OSVersion.Version > new Version(4, 11, 0, 0) 判断。

在 ARM 平台上使用 .NET Core SDK

2018年5月更新:
.NET Core 2.1 已提供 ARM 架构的 SDK binary,可参考 Dockerfile 下载使用。
—-

最近用 ARM 开发板搭了一个 mini PC,想偶尔拿来做 .NET 开发,不过遗憾的是 .NET Core 目前并没有提供 ARM 版的 SDK。

微软的开发人员在 GitHub issue 里给出的理由是目前没有什么性能足够好的 ARM 设备可以支撑一般的开发,不过这个说法其实是站不住脚的。ARM 平台并不是只有树莓派这种廉价设备,如果把预算加到 $200 左右,再买一些 SSD 之类的配件,编译运行一般的 .NET 项目是完全足够了。如果 $200 的板子性能还不够用,就上 $500 的,例如 NVIDIA Jetson 系列的性能足够覆盖大部分 .NET Core 的开发。

不过如果你使用的是没有大核的 ARM 设备,比如绝大多数 $100 以下的开发板,那么不建议使用 SDK 来进行开发,因为启动速度会慢到让你跺脚。雪上加霜的是 .NET Core ARM 版的 crossgen 几乎没什么用,所以每一次启动都要 full JIT。即使是在 2.0 GHz 的 Cortex-A72 核心上,运行 dotnet –help 也需要超过1秒的时间。树莓派上可能要5秒。

除此之外 SDK 还有一个重要的功能就是运行测试(dotnet test),而这个跟性能并没有什么关系,但是对项目开发来讲是至关重要的。

考虑到 .NET Core SDK 完全使用 C# 编写,没有任何 native code,编译一个 ARM 版也不是什么难事。

Steps:

1. 安装 .NET Core(参考

需要注意的是目前 .NET Core 还没有发布 AArch64 的 runtime 包,虽然从源码编译已经有 AArch64 支持,但是这样很麻烦。为了尽可能简化开发,我们选择 ARMv7 版本的 .NET Core 2.0。

如果你用的是 AArch64 架构的 OS,那么除了基于 dpkg 的发行版之外,几乎没有别的选择。所有基于 rpm 的发行版目前基本都不支持 AArch64 架构安装32位 ARM 的包;Arch Linux ARM 也不支持。

2. 编译 .NET SDK

理论上纯 .NET 程序可以通用于不同架构,但是 .NET CLI 在 Build 过程中使用了 crossgen,直接从现有的 .NET Core SDK 中把文件 copy 出来是不能用的。因此需要自己编译,并且 patch 源码,去掉 crossgen 过程。

找一个能够通畅连接国际互联网,并且性能强劲的 x86_64 Linux 机器,运行

git clone https://github.com/dotnet/cli
cd cli
git checkout release/2.1
# 下载并 apply cli.patch

git apply cli.patch
# 编译
./build.sh /t:Compile /p:Configuration=Release

如果不希望自己编译,可以使用我编译的版本

3. 复制 SDK 到 ARM 设备

在本地创建 /opt/dotnet/sdk 目录,并将 cli repo 下的 artifacts/linux-x64/stage2/sdk 目录的内容复制进去。

最终目录结构如下:

dotnet
├── host
│   └── fxr
├── sdk
│   └── 2.1.4-preview-007239
└── shared
   └── Microsoft.NETCore.App

到这一步, .NET SDK 就可以使用了,推荐使用root权限运行一次 dotnet nuget 命令,解压NugetFallbackFolder。

如果在运行 dotnet restore 命令时遇到问题,可以尝试使用 dotnet restore --disable-parallel解决。

 

为什么我不再推荐使用微软服务

TL;DR

因为我的Microsoft帐号在8月被无故suspend,在8月底邮件回答客服提出的所有问题后,就再也没有收到任何消息,帐号也至今仍未取回。

期间我向在线客服咨询过ticket状态,确认了我的回复进入了客户支持系统,也确认了该ticket一直没有后续回复。

我联系过很多次、不同部门的在线客服,大部分的客服都拒绝向我透露帐号被suspend的具体原因,也没有向我透露如何才能解除suspend;有两个客服回答了我原因,但是却是回答的不同原因。

我认为,MS的客户支持服务的水平,以及如此对待客户的态度,导致其消费级服务完全不再具备使用价值。当然,企业服务还是可以放心用的,毕竟企业客服跟个人服务的客服是两回事,而且企业遇到这种事情直接打官司就可以了。

事件回放

8月17日上午,在我个人项目的Slack群里,有人提到了自己的MS帐号被无故suspend

当时我并没有多在意这个问题,但是到了公司之后我发现我自己的MS帐号也无法登录了,登录时显示的内容相同;同时,我的手机、电脑上的微软服务也一个个失效(token过期需要重新登录)。我分享的OneNote笔记本、OneDrive文件也不再可以被其他人访问。

我按照网页上的提示,提交了一个ticket,但是直到8月底为止的两周,我一个回复都没有收到。
在此期间,上面那位帐号被suspend的小伙伴在US多次联系MS,最初得到了全盘否决,MS拒绝解释原因,并且表示永远不会解封该帐号;最后他动用法律手段成功取回了帐号。考虑到他跟我同时被封号,原因应该是相似的,而这从一定程度上能说明我的帐号是被误封,微软自己也不知道怎么解释为什么会被封,一旦客户诉诸法律,就立刻服软。

不过我的处境比较被动,因为我不在US。而微软似乎完全不鸟我,不管我怎么联系,客服就是一个解释都不发给我,也不告诉我下一步具体该怎么做,每次我联系在线客服,仿佛都是得到通稿一般的回复,都是让我重新去填那个网页上的表格再发一个ticket,颇有一种“我就不让你登录,也不告诉你为什么,我就看看你能把我怎么着”的感觉。

8月30日凌晨,我终于收到了微软的邮件,而这封邮件的内容,跟上面那位小伙伴联系微软法务部之后,帐号解封之前,收到的邮件内容一模一样,问了几个相同的问题;我以为我的帐号很快就能解封了,因此在我收到这封邮件之后,我立刻从床上爬起来认真回复。

而这封邮件也是这次事件中微软的客服最后一次主动联系我。

后续

这件事情除了彻底粉碎了MS的个人服务在我心中的形象,因此我决定逐渐解除对任何微软产品的依赖。

9月初购买了一台Intel NUC装上了CentOS 7 GNOME桌面作为主力,并且将安装了Windows的MacBook Air重新装回了macOS出门时用。Linux是我从小玩到大的OS,因此迁移非常顺利。微软 .NET Core的开发工具以及Visual Studio Code在Linux上也非常好用。macOS虽然到现在还用不惯,但是只是出门用,做事时ssh到Linux上就没问题了。其实也是常年吃灰

邮件服务的影响比较大。之前 @live.com 的邮箱用了很多年,而这个邮箱绑定的帐号非常多; @hjc.im 也绑定在同一个帐号的Outlook Premium下,只能暂时迁移出去。

好在微软suspend帐号在不同服务之间是有延迟的,在最初的几个小时里我的Email仍然能从客户端访问,当时我考虑最坏的情况是这个帐号永远取不回来(似乎现在是变成了现实),于是挑了几个价值最大也是最难任意改邮箱的帐号,趁着邮件客户端token没有失效,先收确认邮件把Email改了。剩下的,只能一边翻keepass,一边一个个去尝试更换。
其它的不能随意更改邮箱的服务,只有Origin和Uplay值得找回,因此不得不骚扰客服,提供游戏的购买凭据,好在这些都用几个小时解决了。

今后大概我大部分的服务都会用 @hjc.im 注册,并且尽量多加几个邮箱,多加几个手机号等。

损失最惨的是Xbox,只要一联网就被强制log out,帐号里有价值五六千的游戏,还有几千的Windows Store应用。
好在我的Xbox One S被我设置成了帐号的主要Xbox,因此DRM license全都绑定到了机器。理论上这台机器只要不坏,我就能用别的帐号玩这些游戏,也能下载之前帐号购买过但是没有安装的数字版游戏。因为这个,大概以后还是会继续玩Xbox,不过游戏会尽量买光盘版。索尼PS因为一些别的原因我大概是不会想碰的,不过最近购入了Nintendo Switch,Xbox One S处于吃灰状态。

去年年底购入的Windows Phone最后的旗舰机——HP Elite x3非常悲剧,开了重置保护,而且不登录MS帐号没有办法关闭,因此我只能小心使用,因为一旦需要刷机,或者重置,就无法激活,等同于砖掉了,Windows Phone的重置保护目前没有有效的破解方法。不过我也在计划迁移到安卓。

除此之外,我不再可以访问我的Windows Store开发者帐号,因此之前开发的UWP也无法再更新,甚至连下架都做不到;考虑到UWP现在发展的连情怀都不剩,我也不打算再继续投入时间到Windows应用的开发中。虽然Light Player是我第一个完成度比较高的作品,不过今后我也打算放弃Windows版本的维护,也许会尝试移植到macOS,Linux Desktop和Android。

总结

作为长期的Microsoft软件、服务的用户,在这件事发生之前,我使用的互联网服务基本是能用MS的就用MS的,因此甚至被好多人以为是软粉。
最初只是想省事,虽然MS的服务并非都是最优秀的,但覆盖广泛,结合在一起使用还算方便,支持的平台也够用,最重要的是价格都相对便宜。然而这件事情是狠狠地打醒了我,根本上教育了我绝对不要把鸡蛋放在一个篮子里。

事实上,微软的个人服务很少有不可替代的,或者说很少有非常优秀的,比如Dropbox比OneDrive好用;Google大多数情况下也比Bing好用(除了用于收壁纸);而周围用Skype的人也越来越少,客户端也被改的丑到没法用;Xbox本世代更是被PS打的体无完肤。

因此现在我建议Microsoft的服务、软件,能用替代品的就用替代品,没有替代品的也要留个心眼,方便随时切换到替代品。我想在这件事情中,我已经亲自用大量的时间、经济损失证明了依赖微软服务是多么愚蠢的行为。

即使我最终取回了这个微软帐号,我也不打算再继续使用Xbox以外的任何有替代品的微软服务。同时我的个人电脑已经迁移到CentOS和macOS,在操作系统、Office软件上也可以逐步去除对微软的依赖。在明年从大学毕业后,我在任何情况下都不再需要自己购买、使用微软的消费级产品。当然,我也会努力帮助身边的人逐步脱离对微软产品的依赖,比如在合适的时机购买MacBook、iPad作为礼物等。

最后,祝微软早日退出消费级市场,好好做一个IBM那样的巨头,好好做Azure就行了,别做这么多没卵用还恶心人的消费级服务。

.NET Core 程序的打包与分发 (Linux 篇 Part. III)

前两篇文章介绍了 .NET Core 程序在 Linux 平台上的两种部署方式。对于服务器程序,使用 .NET Core 的两种部署方式基本上足以满足需求,但是在很多情况下, .NET Core 仍然不是最佳的选择。

如果你想使用 .NET Core SDK,或者使用 .NET Standard,同时又有以下需求之一:

  1. 在非 x86/amd64 架构上部署程序,比如部署到 ARM 或者 MIPS 架构
  2. 在 Linux 平台上做开发,但部署的目标包含 FreeBSD 等 .NET Core 并未官方支持的平台
  3. .NET Core 2.0 仍未支持你所需要的 .NET Framework API,但 mono 却有较好的支持
  4. .NET Core 2.0 对你所需要的发行版支持仍然非常差

那么可以考虑使用 .NET Core SDK 完成开发,但是使用 mono runtime 作为最终的应用分发方式。

除此之外,使用 .NET Core SDK 搭配 mono runtime 使用,还能在 Linux 上生成 Windows 的 .NET Framework 程序。

需要注意的是,这种方式虽说是可行且受支持的,但是在官方文档里并没有被提及,只在 GitHub issue 中提到过。可能使用 mono 的 reference assembly 算是一种 hack。

环境

开发环境与部署环境均为 Arch Linux。前者安装 FPM.NET Core SDKmono

选择 Arch Linux 是因为 .NET Core 2.0 目前对其有非常好的支持,并且发行版软件源里 mono 版本足够新,不像其它常用发行版那样需要从第三方源(Xamarin)安装 mono。

示例程序

本文仍然沿用前文的 sslver 示例(源码)。这次与前文不同的是,需要修改 csproj 文件。

mkdir ~/sslver
cd ~/sslver
dotnet new console

修改 Program.cs 为示例程序后,编辑 sslver.csproj,将 TargetFrameworknetcoreapp2.0 改为 net462
以下为修改后的 csproj 文件:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net462</TargetFramework>
  </PropertyGroup>
</Project>

构建

尝试直接使用 dotnet build 命令,会报错:
/opt/dotnet/sdk/2.0.0/Microsoft.Common.CurrentVersion.targets(1122,5): error MSB3644: The reference assemblies for framework ".NETFramework,Version=v4.6.2" were not found. To resolve this, install the SDK or Targeting Pack for this framework version or retarget your application to a version of the framework for which you have the SDK or Targeting Pack installed. Note that assemblies will be resolved from the Global Assembly Cache (GAC) and will be used in place of refe:rence assemblies. Therefore your assembly may not be correctly targeted for the framework you intend. [/home/user/sslver/sslver.csproj]

这是因为 .NET Core SDK 找不到编译 .NET Framework 4.6.2 程序所需的 reference assembly。幸运的是,mono 提供了这些 reference assembly,我们可以直接在 .NET Core SDK 中使用它们。

使用命令 export FrameworkPathOverride=/usr/lib/mono/4.6.2-api/ 设置环境变量。

FrameworkPathOverride 用于覆盖默认的 .NET Framework reference assembly 目录,而 /usr/lib/mono/4.6.2-api/ 就是 mono 提供的 .NET Framework 4.6.2 reference assembly 的所在位置。

如果今后需要使用更新版本的 .NET Framework,只需修改 csproj 中的 TargetFramework 版本,和 FrameworkPathOverride 环境变量所指向的目录即可。

再次尝试 dotnet build -c=Release -f=net462 --output pack_root/opt/sslver/ ,输出:
sslver -> /home/user/sslver/pack_root/opt/sslver/sslver.exe
可以看到,成功地生成了 sslver.exe

不同于之前 .NET Core 所生成的 dll 文件,目标 .NET Framework 生成的是 exe 后缀的文件,可以直接在 Windows 平台上运行,也可以使用 mono 运行。

使用命令 mono pack_root/opt/sslver/sslver.exe 即可看到程序输出目前的 OpenSSL 版本: libcrypto 1.1.0f

打包

上面的步骤生成的文件全部位于 pack_root/opt/sslver 下。生成目标是 .NET Framework 时,生成的文件数量较少,本示例中只生成了 sslver.exesslver.pdb 两个文件,而我们只需要分发前者。

与共享 runtime 的 .NET Core 部署方式类似,为了能让 sslver 命令直接运行我们的程序,我们需要在 pack_root/usr/bin 下创建一个 stub 脚本:

mkdir -p pack_root/usr/bin
cat > pack_root/usr/bin/sslver <<__EOF__
#!/bin/sh
exec /usr/bin/mono /opt/sslver/sslver.exe "\$@"
__EOF__

设置权限

find pack_root/ -type d | xargs chmod 755
find pack_root/ -type f | xargs chmod 644
chmod +x pack_root/usr/bin/sslver

调用FPM完成打包

fpm -s dir -t pacman --name "sslver" -C pack_root/ --version 1.0.0 --iteration 1 --description "Displays OpenSSL Version" \
--depends mono --depends openssl \
--package ./

输出 Created package {:path=>"./sslver-1.0.0-1-x86_64.pkg.tar.xz"}

将文件移动到部署环境中,使用命令 pacman -U sslver-1.0.0-1-x86_64.pkg.tar.xz 安装,在安装了 mono 和一大坨依赖之后,就可以使用 sslver 命令检查打包是否成功了。

注意事项

  1. 虽说使用 .NET Core 生成 .NET Framework 或 mono 的可执行文件是可行的,但目前 .NET Core SDK 在非 Windows 平台下并不支持使用 dotnet run 调用 mono 运行 .NET Framework 目标的程序(相关issue);同时,dotnet test 也不支持使用 mono。也就是说基本只有 dotnet build 可以正常使用。
    虽说这些局限并不影响编译,但是却相当影响开发与测试。无法使用 SDK 自带的测试框架意味着编写测试代码等需要手动进行。而常用开发工具(如 Visual Studio Code)对 mono target 的支持也不是特别好。
    针对这一问题,我目前不推荐将 mono 作为唯一的 target,而是建议先用 .NET Core target 进行开发,再针对 .NET Framework 构建,在 mono runtime 下运行。
  2. 在 .NET Standard/.NET Core 下,我们通常使用使用 System.Runtime.InteropServices.RuntimeInformation 这个 API 来判断程序运行的平台。但是这个 API 在 .NET Framework 下是写死的返回 Windows,因此需要使用 Environment.OSVersion 这个 .NET Framework API 进行判断。如果你需要同时兼容 .NET Core 1.x 和 .NET Framework 而又不想用一大堆 #ifdef,那么可以参考这个答案
  3. 由于 API 实现在细节上有很大的不同,不要依赖于任何 API 的未定义行为,并且要针对目标环境进行完整的测试。
    比如在 .NET Framework/.NET Core 上,Socket 被 Dispose 后,LocalEndPoint/RemoteEndPoint 属性仍然可以被访问,但是在 mono 上,会直接抛出 ObjectDisposedException 异常。
  4. 部署环境中的 mono 一定要足够新,如果是 CentOS 或 Debian 之类的发行版,建议添加 mono 官方源

总结

使用 .NET Core SDK 开发,mono 分发和运行程序的情况并不常见,但考虑到目前 .NET Core 的平台支持仍然相当受限,如果你想开发一个非常流行的程序,那么还是建议支持 mono,并做好测试。

事实上,自从 Microsoft 把 .NET Framework 的 Reference Source 从 MSRSL 改成 MIT 开源,并且开发 .NET Core 后,mono 可以直接从 Microsoft 的开源项目中 copy 代码,因此实现的 API 在行为上与 .NET Framework 都更加接近了。同时,.NET Standard 2.0 也添加了大量原本仅存在于 .NET Framework 中的 API。我们可以推断,将来编写与维护同时支持 mono 和 .NET Core 的程序会越来越容易。