1. 在线编程环境到底在解决什么问题
先聊点实在的。我最初接触"在线写 C#"这个需求,是给一个面向初学者的小型互动教程做配套练习环境。需求很朴素:用户打开浏览器,左边写代码,右边出结果,不用装 Visual Studio,不用装 .NET SDK,点一下按钮就能跑。听起来简单,真正动手才知道,一个能稳定跑起来的 C# 在线环境,复杂度远超预期。
SharpPad 这个名字,是我给这个项目起的内部代号。它本质上是一个 Web 应用,前端提供编辑器、代码补全、编译错误展示,后端负责真正的编译与执行,还要解决一个最棘手的问题:用户代码不能把服务器搞崩。这就要涉及沙箱隔离、资源限制、进程管理、超时控制,整套东西串起来,才配叫"在线编程环境",而不是一个简单的"网页版记事本+跑命令"的演示品。
这个项目适合谁参考?如果你准备做在线教学平台、代码评测系统、内部工具站里的脚本执行器,或者单纯想深入理解 C# 编译链路和 .NET 运行时的边界,这篇拆解大概率能帮你少走几个月的弯路。我会从架构选型讲到编译流水线,再讲沙箱执行、任务调度,最后附上我踩过的坑和排查思路,尽量把关键决策背后的"为什么"说透。
2. 前端编辑器:Monaco 与 C# 语言服务的接入
在线编程环境的第一印象,几乎全由编辑器决定。用户不会关心你后端用了什么黑科技,只会关心"代码写着顺不顺手""报错是不是即时"。
2.1 为什么选 Monaco Editor 而不是别的编辑器
编辑器这块,可选方案无非 CodeMirror、Ace、Monaco 三选一。我选 Monaco,核心原因是它和 VS Code 同源,底层是同一个 Web 编辑器内核,对 C# 这种重语法语言的体验天然更好。这里有个容易忽略的细节:Monaco 自带 Monarch 语法高亮,但 C# 的高亮规则写得好不好,直接决定代码看着是否舒服。默认的 C# 语法高亮其实偏弱,关键字、类型、方法名的颜色区分不够细,我后来自己改写了部分 Monarch Tokenizer 规则,把所有 C# 上下文关键字(比如async、await、record、init)都纳入了高亮范围,才达到接近桌面 IDE 的观感。
另一个选 Monaco 的理由是它内置了 Diff 编辑器和 Minimap,做代码对比和长文件导航很方便。在线教学场景里,用户经常要对照"我的代码"和"参考答案",Diff 视图一个组件就解决了,省了再引第三方库。
2.2 代码补全和语义诊断是怎么做到的
光有编辑器外壳远远不够。C# 用户最依赖的两个能力,一个是输入时的智能补全,一个是实时的语义级错误提示。晦涩一点说,这需要语言服务协议(LSP)的支撑。
我在项目里没有重复造轮子,而是直接在后端塞了一个 Roslyn 语言的 Workspace,把前端 Monaco 的monaco.languages.registerCompletionItemProvider接到 WebSocket 通道上,每次用户输入停顿 300 毫秒后,前端把当前文档全文和光标位置发给后端,后端用 Roslyn 的SemanticModel做一次增量分析,返回补全列表和诊断信息。
这里有个坑值得说:如果每敲一个字符就发一次全量文档,WebSocket 消息量会非常恐怖,同时后端 Roslyn 每次都重建语法树也扛不住。我最后的策略是"节流+全量重建":前端节流 300ms,后端拿到代码后做一个轻量的IsChanged判断,如果只有光标位置变了而文本没变,直接复用上一个语法树,只重算光标处的语义数据。文本变化了才重建语法树。这样实测下来,一个 500 行以内的文件,后端分析耗时控制在 50ms 以内,体验才算过关。
2.3 前端与后端的实时通信通道设计
编辑器和后端的通信,我用的是 WebSocket,而不是普通的 HTTP 轮询。主要原因有两个:一是补全和诊断属于高频低延迟交互,WebSocket 长连接省掉了每次 HTTP 握手的时间;二是代码执行的状态(开始编译、执行中、输出回传)是阶段性推送的,用 WebSocket 天然适合做这种服务端主动推送的场景。
消息协议我设计得比较薄,就是一个 JSON 对象,包含type和payload两个字段。type 有completion、diagnostic、compile、execute、output、executionComplete等几类。早期版本我图省事,直接用文本协议,后来发现 JSON 的可读性和可调试性远超自造协议,在排查问题的时候这一点尤其宝贵。
3. 后端核心:Roslyn 编译与 .NET 程序集加载
前端只是面子,后端才是里子。C# 在线环境里,真正决定架构水平的地方,是把"用户代码"变成"可运行程序"的那条编译流水线。
3.1 编译方案选型:Roslyn vs 命令行 csc
业内做 C# 在线编译,无非三条路:直接调用系统安装的csc命令行、用 Roslyn 编译 API、或者动态生成 .NET 项目然后用dotnet build。我的选择是 Roslyn 编译 API,Microsoft.CodeAnalysis.CSharp包。原因如下:
命令行csc简单,但错误信息的结构化程度低,解析起来费劲,而且每次启动进程都有开销,编译吞吐量上不去。dotnet build更重型,虽然在真实项目还原 NuGet 依赖上有优势,但对于单文件在线场景,构建耗时完全不可接受。Roslyn 直接跑在进程内,不启动子进程,第一次编译完语法树还能缓存,吞吐量高出一个量级。
核心代码长这样:
var syntaxTree = CSharpSyntaxTree.ParseText(userCode, new CSharpParseOptions(LanguageVersion.CSharp12)); var references = AppDomain.CurrentDomain.GetAssemblies() .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location)) .Select(a => MetadataReference.CreateFromFile(a.Location)) .Cast<MetadataReference>() .ToList(); var compilation = CSharpCompilation.Create("UserCodeAssembly") .WithOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) .AddReferences(references) .AddSyntaxTrees(syntaxTree);这里最关键的细节是MetadataReference的处理。在线编译常见的坑是用户代码用到了System.Text.Json、System.Net.Http等运行时程序集,如果引用的程序集列表不全,编译会报一堆"找不到类型"的错误。我一开始的做法是反射拿AppDomain.CurrentDomain.GetAssemblies(),筛选出有物理路径的引用,后来发现运行时加载的程序集并不完全等于所有可用程序集,部分框架程序集未被加载就会漏掉。最终稳妥做法是显式加载 .NET 运行时目录下的核心程序集列表,再加上当前进程已加载的程序集,两者合并去重。
3.2 编译结果处理:程序集二进制与 PDB 符号
编译通过后,需要把MemoryStream里的程序集二进制传给执行模块。Roslyn 的Emit方法支持写入多个流,其中 dll 主输出流和 pdb 符号流都要保留下来。PDB 这个容易忽略,但对在线调试和异常堆栈解析非常重要。如果用户代码抛出异常,没有 PDB 的话堆栈信息里只有 IL 偏移,不包含文件行号,对用户排查问题极不友好。
我处理的方式是:
using var peStream = new MemoryStream(); using var pdbStream = new MemoryStream(); var emitResult = compilation.Emit(peStream, pdbStream); if (!emitResult.Success) { // 收集 Diagnostic 并格式化为 JSON 返回前端 }拿到MemoryStream之后,一个问题是程序集要存到哪里。有两种做法:一种是存内存对象,直接在当前进程的AssemblyLoadContext里加载执行;另一种是落到临时文件,用独立的进程加载。这个决策直接影响整个沙箱方案,下面会展开讲。
3.3 动态加载执行的核心机制:AssemblyLoadContext
如果用进程内加载,就绕不开AssemblyLoadContext。 .NET Core 3.0 以后,Assembly.Load(byte[])这种老方式会在默认上下文里加载程序集,卸载不掉,执行几次内存就涨上去了。所以在线上环境里我用的必须是可收集的AssemblyLoadContext:
public class CollectibleAssemblyLoadContext : AssemblyLoadContext { protected override Assembly Load(AssemblyName assemblyName) { // 优先从默认上下文返回已知框架程序集 return null; } protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) { return IntPtr.Zero; } }但这里还有个更现实的问题:进程内加载用户程序集,本质上就是在一个进程里执行用户代码,一旦用户代码刚启动就Environment.Exit(0),或者死循环、OOM、栈溢出,整个宿主进程都会跟着遭殃。哪怕用了独立AssemblyLoadContext,Environment.Exit这种"自杀式"操作根本拦不住。所以我的结论是,稳妥的在线执行环境必须做进程级隔离,进程内加载只在"编译验证"阶段用,实际执行交给子进程或容器。
4. 执行沙箱与资源隔离设计
沙箱是整个在线编程环境里最容易低估难度、也最容易翻车的部分。我见过不少项目刚上线时跑得好好的,被用户提交了一段while(true){}就直接卡死整个服务。所以沙箱方案一定要在执行资源和进程边界两方面同时下手。
4.1 三种隔离级别的取舍:进程、容器、还是完全专用服务器
这里有三条路线:一条是"进程级沙箱",即在受控的独立进程里执行代码,宿主进程通过进程 API 监控和杀掉子进程;一条是"容器级沙箱",用 Docker 等容器技术给每次执行起一个一次性容器;还有一条是"专用执行服务器",把执行任务单独部署到独立机器,彻底物理隔离。
如果你是做一个中小型在线环境,进程级沙箱是性价比最高的起点。容器级沙箱隔离性最好,但开销和运维复杂度显著上升,每个容器启动都是秒级等待,交互体验会打折扣。专用服务器适合规模化商用场景,前期不必要。
4.2 基于独立进程 + Job Object 的执行器设计
我这边的实现是这样:宿主进程每次接收执行请求后,启动一个新的 dotnet 子进程,子进程加载用户程序集并运行入口方法,宿主通过进程句柄监控子进程的资源占用和运行时间。在 Windows 上我还会把子进程挂到一个 Job Object 上,这样即使子进程再派生出其他进程(比如用户代码里调用了Process.Start),也能一并被限制在同一个 Job 里。
这里有个很关键的细节:子进程的入口程序集不是用户程序集本身,而是我提前打包的一个"执行宿主",它负责加载用户程序集、调用Program.Main或某个约定的入口方法,并截获Console.Out和Console.Error。用户代码的输出不会直接打到终端,而是由执行宿主通过一个管道回传给父进程,父进程再通过 WebSocket 转发给前端。这样做的好处是输出可以实时增量推送,而不是等整个程序跑完才一次性吐出来。
4.3 资源限制的落地做法与参数计算
资源限制主要管四样:CPU 时间、内存上限、执行超时、文件大小。CPU 时间限制在 Windows 上最容易的方式是用 Job Object 的JOBOBJECT_BASIC_LIMIT_INFORMATION设置PerJobUserTimeLimit,在 Linux 上则可以用setrlimit(RLIMIT_CPU)。内存上限直接用进程虚拟内存上限来控制,C# 进程启动时本身会预留不少虚拟内存,所以需要把限制设置得比实际预期内存大一到两个数量级,否则会出现用户代码什么都没干,进程就被误杀的情况。
超时控制我用的是三层:第一层是 Job Object 的硬性 CPU 时间限制,第二层是执行宿主内部自己计时,第三层是父进程的看门狗线程。父进程看门狗每 500ms 检查一次子进程是否还在运行,超过设定阈值就直接Kill()整个 Job。我实测过,三层配合比单层可靠得多,因为单靠看门狗可能存在杀不干净子进程的情况,而单靠 Job Object 硬限制没办法做执行结果的优雅回收。
参数我通常这么设置:
| 资源项 | 默认值 | 说明 |
|---|---|---|
| 执行超时 | 5s | 初学者题目执行一般 1s 内完成,5s 足够 |
| CPU 时间上限 | 3s | 死循环代码会被提前强杀 |
| 内存上限 | 256MB | 包含 CLR 运行时本身开销 |
| 子进程数上限 | 8 | Job Object 内最大活动进程数 |
| 输出长度上限 | 200KB | 防止恶意打印撑爆内存 |
注意,内存上限不能设得太小。.NET 运行时本身的 GC 堆和 JIT 开销就可能占掉几十 MB,我早期设 64MB,结果一连串正常代码全部被误杀,排查了很久才发现是阈值太紧。
4.4 代码访问权限与文件系统隔离
用户代码默认运行在一个独立的工作目录里。这个目录每次执行前都会重新创建,执行结束后整体删除。所有文件操作都限制在这个目录里,网络访问默认禁用——具体做法是在执行宿主里给用户代码塞一个禁用的HttpClient工厂,并且在进程启动时用Environment.SetEnvironmentVariable("DOTNET_SYSTEM_NET_HTTP_ENABLEACTIVITYPROPAGATION", "0")这种手段做防御,真正物理上的网络隔离还得靠防火墙规则或容器网络模式。
文件系统隔离方面,Linux 上我用的是chroot或者unshare做轻量隔离,Windows 上则是构造一个受限的 ACL 用户来运行子进程。如果你前期不想搞这么复杂,至少要把工作目录设为临时目录,并确保宿主进程自己的配置文件和密钥绝对不在用户代码可访问的路径范围里。
5. 任务调度、缓存与并发控制
在线编程环境的访问模式非常有特点:大量用户同时请求编译执行,单次执行耗时短则几十毫秒,长则几秒(比如死循环被超时杀掉)。如果所有请求都直接打给编译器和执行进程,系统很容易在高峰期雪崩。所以调度层不是可选项,而是必备组件。
5.1 为什么需要任务队列而不是同步直连
最容易犯的错是把"编译"和"执行"都做成同步操作。早期原型阶段我就是这么干的:Web API 收到请求,同步编译,同步执行,同步返回结果。看起来代码最简单,但一旦一个执行任务卡在死循环超时上,后续所有等待中的请求都会排长队。哪怕执行进程被杀了,一部分 WebAPI 线程也已经被占用,吞吐量立刻崩盘。
改成任务队列之后,所有执行请求先进入一个阻塞队列,后台有固定数量的 Worker 逐个消费。请求方通过 WebSocket 监听执行状态,编译任务完成、执行开始、执行结束、输出更新这些状态都通过事件推送。延迟上会多出几十毫秒的排队时间,但换来的是整个系统不会因为一个坏代码而全局瘫痪。
5.2 并发度设置与线程池取舍
关键参数是 Worker 数。设多了,线程上下文切换频繁,CPU 资源被白白消耗;设少了,高峰期排队时间太长。我根据服务器配置做了压测,结论是 Worker 数大致等于 CPU 核心数减一,并且每个 Worker 在执行期间不能再启动额外的并行任务。原因很简单:用户代码是单进程执行的,它自己会占用一个完整的 CPU 时间片,如果 Worker 数超过 CPU 核心数,多个用户代码同时在跑,互相争抢 CPU,极端情况会导致单个执行任务被拉长到超时才结束,体验非常糟糕。
任务队列我用的是无界队列加拒绝策略。队列长度达到 100 时,新的执行请求会直接返回"服务器繁忙"提示,而不是无限堆积。因为堆积的任务延迟越来越高,用户体验会持续恶化,早一点拒绝反而利于保护整体服务质量。
5.3 编译缓存:大幅度减少重复编译开销
在线环境的第二优化点是编译缓存。同一个用户反复修改同一份代码,语法树结构大部分时候变化不大。我的策略是以"用户提交的代码 hash"为 key,缓存上次的编译结果,代码没变就直接返回缓存,避免重复 Parse 和 Emit。这里有个边界情况:相同代码在不同用户手里可能要引用不同的程序集,所以缓存 key 里除了代码 hash,还要加上"引用程序集集合的版本号",否则两个用户代码相同但需要不同引用时会互相污染。
缓存策略还加了一层"按需失效":当 Roslyn 版本或 .NET 运行时版本升级时,整个编译缓存全部清空。我在部署脚本里加了一个版本号环境变量,每次升级递增,缓存 key 也包含这个版本号,这样就不会出现新运行时加载旧编译结果导致不兼容的问题。
6. 实测案例与踩坑记录
讲了这么多设计和实现,这部分分享几个我实际遇到过的经典问题和排查过程。这些问题几乎每个都是文档里查不到、只有跑过线上数据才暴露的。
6.1 现象:编译正常但执行时程序集找不到依赖
有一次部署到一台只装了 .NET Runtime 的干净服务器,用户代码里用了System.Text.Json,编译没问题,执行时直接抛FileNotFoundException,提示找不到System.Text.Json.dll。排查半天发现,开发机上编译时引用的程序集路径是"引用程序集",不是运行时自带的实现程序集。我的MetadataReference.CreateFromFile传入的是开发机的引用程序集路径,但目标服务器上运行时加载的却是实现程序集,名称相同版本号不同,导致执行阶段解析失败。
解决办法很简单:编译引用统一使用运行时实际加载的程序集集合(AppDomain.CurrentDomain.GetAssemblies()过滤出的有 Location 的项),而不是直接引用硬盘上的"引用程序集"目录。运行时加载哪些程序集,就编译引用哪些程序集,两边完全对齐,这个问题就再也没出现。
6.2 现象:用户代码输出大量数据导致前端卡死
这个坑出在输出通道上。用户代码里一个简单的for循环打印 10 万行文本,执行进程本身没超时,但 WebSocket 把 10 万行文本一行行推给前端,前端 DOM 不断增长,直接卡死浏览器。我最初只限制了"输出总长度 200KB",但是没想清楚 200KB 的数据如果是逐行推送,页面的渲染压力远大于数据量本身。
最终处理是在前端做"输出行数上限"控制,超过 2000 行就折叠剩余内容,只显示"输出已达上限"的提示;同时后端在输出管道里做了节流:把输出流攒 100ms 再批量推送一次,避免高频小包轰炸网络和前端。
6.3 现象:偶发的执行进程杀不干净
这是一个比较隐蔽的问题。用户代码里如果开启了异步任务或者后台线程,主进程即使被Kill了,后台线程对应的子线程也可能继续存活,占着 CPU 和内存不放。我一开始用Process.Kill()只杀了主进程,部分场景下会出现执行进程消失,但 CPU 占用率依然飙高的情况。
解决办法就是把前面提到的 Job Object 机制用到位。在 Windows 上创建子进程时把它 Assign 给 Job Object,杀进程时通过TerminateJobObject把整个 Job 内所有进程一锅端;在 Linux 上使用 process group 将子进程和它的所有后代进程归组,杀进程时kill(-pgid)。这一层隔离是"进程沙箱"方案里最容易被忽略、却是最保命的细节。
6.4 现象:并发编译时内存暴涨
Roslyn 编译本身是 CPU 密集和内存密集操作,当 16 个 Worker 同时编译用户代码时,内存峰值很容易冲到 2GB 以上。我一开始没做限制,导致了两次线上 OOM。后来做了两层优化:第一层限制同时执行编译的线程数,把编译并发压到 4;第二层是在编译前先估算用户代码的语法树复杂度,如果代码行数超过 5000 行或语法树节点数超过阈值,直接拒绝编译并返回提示"代码过于复杂"。实测下来,OOM 问题彻底解决,正常用户不受影响,恶意提交大文件也被挡在了门口。
7. 一些值得后续扩展的方向
这个架构目前已经稳定支撑了我这边几个教学场景的使用,但整体来看还有不少可以继续完善的地方。简单列几个方向,供参考。
一是把执行环境做成 Docker 容器化版本。和进程级沙箱相比,容器在文件系统隔离、网络隔离和依赖管理上都会省心很多,尤其是用户代码需要引用第三方 NuGet 包时,容器里预装依赖显然比宿主进程里逐个加载更现实。二是加上持久化能力,让用户代码可以保存到服务端,支持多设备同步,配合 Monaco 的 Diff 视图还能做版本对比。三是引入真实调试能力,虽然基于 Roslyn 的单步调试在 Web 端实现复杂度很高,但现在已经有开源方案可以参考,体验够好的话会是一个极大的差异化优势。
最后再说一个我个人的体会:构建在线编程环境,技术栈本身并不神秘,真正花时间的地方全在边际细节——进程杀干净没、内存超了没、输出刷爆了没、并发排队公平不。这些坑,靠读文档是读不出来的,只有在真实跑起来之后才会一个接一个地冒出来。我写完这个项目之后最大的感受是,在线 IDE 看上去是"把编辑器搬上网页",实际上它是编译器和运行时的一场复杂适配工程,每一个环节都需要围绕"不可信代码"这个前提来设计。希望这篇拆解能帮后入场的人少踩几个我踩过的坑。