April 25, 2022

Running .NET in WebAssembly

Ivan Towlson

csharp dotnet webassembly spin wagi

Running .NET in WebAssembly

It’s common to describe C# as an object-oriented language for big Microsoft shops. But nowadays that sells it short. C# has increasingly moved away from its conservative roots, thoughtfully bringing on features from functional and research languages, and gradually shedding ceremony to compete with leaner languages. Today, C# is estimated to be the fifth most popular programming language in the world.

C# targets the .NET runtime, a language-neutral execution environment that runs a low-level bytecode. Other .NET languages include F#, a functional-object hybrid with a vibrant open source and data science community, and Microsoft’s Visual Basic, a .NET dialect of an old enterprise favourite. Traditionally, the .NET runtime has been a native executable. But recently we’ve seen .NET starting to arrive on WebAssembly. And with it comes C# - and every other .NET language.

In this post, we’ll look at how to write and build a server-side WebAssembly app in C#.

C#, .NET, and WebAssembly

WebAssembly is a binary executable format. C# already compiles one binary executable format, .NET bytecode, but changing it to target another is tricky for a bunch of reasons. For a start, the C# language is deeply entwined with the .NET standard library. For another, C# gets a lot less interesting if you can’t use its NuGet package ecosystem, and in .NET land, packages are distributed as bytecode binaries. Plus, if you compiled C# to Wasm bytecode, that wouldn’t help you with other .NET languages.

So .NET has taken a different approach to Wasm, one a little more akin to the Python approach. Instead of compiling C# to Wasm bytecode, the strategy is to compile the .NET runtime to Wasm bytecode. This means that any .NET bytecode - whether it’s a C# or F# program, or a third-party NuGet binary package - should ‘just work’, because as far as the program is concerned, it’s just in the .NET runtime. Just as a Python program doesn’t care that the Python interpreter is running on Wasm, the .NET code doesn’t care that the .NET runtime is running on Wasm.

We’re skipping over a lot of details here. We’ll come back to some of those as we progress. But it’s time to get our hands dirty.

Getting the .NET WASI SDK

There’s already a limited .NET runtime for Web pages called Blazor, but we’re interested in running Wasm on the server, so we need the Experimental WASI SDK for .NET Core. (Microsoft and .NET might be all open-source and cross-platform and all that, but they still love them some wordy naming.) You’ll also need some prerequisites.

Get .NET 7 Preview 2 or above

You can get .NET 7 previews from the download site. The WASI SDK repository says you need Preview 4 or above, but that’s not up at the time of writing; fortunately, it seems like Preview 2 works for what we need to do.

Make sure you have the right version by running dotnet --version - you want to see 7.0.100-preview.2 or above.

Build the WASI SDK

You will need to build the WASI runtime from source. For this you will need to be on Linux or WSL. Put aside some time - even on a fairly fast machine!

  • Clone the SDK repo: git clone https://github.com/SteveSandersonMS/dotnet-wasi-sdk
  • Change to the SDK directory and pull in the runtime module source: git submodule update --init --recursive
  • Follow the build instructions in the read-me

After this is all done you will likely need to open a new terminal window or tab.

To confirm that it’s working, change to the samples/ConsoleApp directory and run dotnet build. You should get a file ConsoleApp.wasm in the bin/Debug/net7.0 directory.

Building the application

Now we’re set up, we can build our own application. For this article we’ll build a simple Web page to run with Spin.

  • Change to your favourite scratch directory
  • mkdir SpinPage
  • cd SpinPage

The .NET WASI SDK doesn’t yet support the component model, so we’ll use it in WAGI (WebAssembly Gateway Interface) mode. You won’t need the WAGI binary, because Spin implements WAGI - the link is so you can check out the specification. For now all you need to know is that WAGI is a WebAssembly version of the venerable CGI standard. That is, it serves Web pages simply by writing them to standard output, or, in .NET speak, the console.

So we’ll create a C# console app:

dotnet new console

Now we need to reference the WASI SDK. The SDK will modify how the project gets built, so that it produces a Wasm module instead of a .NET executable.

dotnet add package Wasi.Sdk --prerelease

And open Program.cs and change it to the following:

using System.Runtime.InteropServices;

Console.WriteLine($"Content-Type: text/html");
Console.WriteLine();
Console.WriteLine($"<head><title>Hello from C#</title></head>");
Console.WriteLine($"<body>");
Console.WriteLine($"<h1>Hello from C#</h1>");
Console.WriteLine($"<p>Current time (UTC): {DateTime.UtcNow.ToLongTimeString()}</p>");
Console.WriteLine($"<p>Current architecture: {RuntimeInformation.OSArchitecture}</p>");
Console.WriteLine($"</body>");

Run dotnet build. You should now have a Wasm module bin/Debug/net7.0/SpinPage.wasm.

Let’s hook this up to Spin. Create a new file spin.toml in your SpinPage directory, and change it to the following:

spin_version = "1"
name = "spin-test"
trigger = { type = "http", base = "/" }
version = "1.0.0"

[[component]]
id = "spin-page"
source = "bin/Debug/net7.0/SpinPage.wasm"
[component.trigger]
route = "/"
executor = { type = "wagi" }

And start Spin:

spin up

You should see a message Serving HTTP on address http://127.0.0.1:3000. Click the link to view your page!

If you see an error “failed to find function export canonical_abi_free” or similar, check you remembered the executor line in the spin.toml file. Spin defaults to using the Wasm component model; the message is telling you that the .NET WASI runtime doesn’t yet support that model.

If it takes a long time to start, check that you’re using the Release build of Spin. The underlying Wasmtime library takes a very long time to load large modules when in Debug mode.

Right now, our program doesn’t do an awful lot - we get a couple of values from the environment and runtime, but other than that it’s all static text. But you can use most of the .NET Base Class Library, including types like System.Environment for getting WAGI environment variables, and System.IO.File for open templates or static files. We’ll get more adventurous in future posts, or see the csharp-... and fsharp-... directories in the Kitchen Sink demo. But let’s close for now with a look behind the scenes.

What’s happening here?

All right, we’ve proved that we can build WebAssembly modules from C#, and run them using a WASI-compatible execution environment such as Spin. How does it work? What’s going on behind the scenes?

To be clear, you don’t need to know. As a developer, you just write C#; as a user, you just run the Wasm file. But if you’re kicking the tyres on a preview like this, we’re guessing you’re at least a little bit curious about how it works. So let’s dig in!

If you watch the output of the build command, you can see it “bundling” your compiled application and the .NET DLLs it depends on:

SpinPage -> /home/ivan/SpinPage/bin/Debug/net7.0/SpinPage.dll
1/10 Bundling SpinPage.dll...
2/10 Bundling System.Collections.dll...
3/10 Bundling System.Memory.dll...
4/10 Bundling System.Private.Runtime.InteropServices.JavaScript.dll...
5/10 Bundling System.Console.dll...
6/10 Bundling System.Threading.dll...
7/10 Bundling System.Private.CoreLib.dll...
8/10 Bundling System.Runtime.InteropServices.dll...
9/10 Bundling System.Runtime.dll...
10/10 Bundling System.Private.Uri.dll...
SpinPage -> /home/ivan/SpinPage/bin/Debug/net7.0/SpinPage.wasm

And you can see that the .wasm file is large compared to the .dll file:

$ wc -c bin/Debug/net7.0/SpinPage.dll
5120 bin/Debug/net7.0/SpinPage.dll
$ wc -c bin/Debug/net7.0/SpinPage.wasm
16053525 bin/Debug/net7.0/SpinPage.wasm

The SpinPage.wasm file contains the .NET runtime, and all the DLLs - your application and all the DLLs it depends on. The DLLs aren’t compiled to WebAssembly. They contain the usual .NET bytecode. This is very much the same as the standalone binary of a normal .NET application. But all the infrastructure that’s needed to extract and run that bytecode is now in Wasm rather than in native x64.

You can even disassemble the SpinPage.wasm file and look at the $__original_main function. You’ll see calls like this:

// Many intervening lines omitted between each call!
call $dotnet_wasi_registerbundledassemblies
call $mono_wasm_load_runtime
call $dotnet_wasi_getentrypointassemblyname
call $mono_assembly_open
call $mono_wasm_assembly_get_entry_point
call $mono_wasm_invoke_method

So when Spin or Wasmtime runs the .wasm file, the Wasm code:

  • Creates a map from assembly identities to the bytecode of the bundled assemblies (in a Wasm data segment)
  • Looks up which assembly contains the entry point (the main function in the source code, implicit in modern C#)
  • Opens that assembly and locates the .NET method corresponding to the entry point (bytecode in the data segment)
  • Calls a Wasm function to execute that bytecode

This is pretty much the same flow as in a native .NET standalone binary. The instruction set is Wasm instead of x64, DLLs are embedded in the Wasm data segment instead of the PE or ELF data segment, but at a high level, the process is very similar. Notice, though, that it happens every time the Wasm module runs. A Wasm module instance is analogous to an operating system process, not to (say) an ASP.NET request handler. This means that you don’t get to amortise the .NET warm-up time across multiple instances. We’ll talk more about this in future posts.

Finally, to reiterate - to you as the developer, or to the user running your code, all this stuff under the hood is all invisible, just as it is in a native .NET runtime. You don’t need to know about it! This is just a peek at how it works internally.

Conclusion

In this post, we’ve seen:

  • How to get the .NET WASI SDK
  • How to build a simple .NET application that runs on Spin
  • What’s in the Wasm file and how it runs

In future posts we’ll look at interacting with WASI features like files and environment variables, and try out some Web and microservice frameworks to see how they go on WASI. But for now, grab the experimental SDK and spin something up!


🔥 Recommended Posts


Quickstart Your Serveless Apps with Spin

Get Started