This blog post first appeared on Fermyon.com, and was written by Ivan Towlson
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 favorite. 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.
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 favorite 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 theexecutor
line in thespin.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.
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 tires 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!
Top comments (0)