-
Notifications
You must be signed in to change notification settings - Fork 5.3k
Memory model document. #75790
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Memory model document. #75790
Conversation
|
I couldn't figure out the best area label to add to this PR. If you have write-permissions please help me learn by adding exactly one area label. |
|
|
||
| These facilities ensure fault-free access to potentially unaligned locations, but do not ensure atomicity. | ||
|
|
||
| As of this writing there is no specific support for operating with incoherent memory, device memory or similar. Passing non-ordinary memory to the runtime by the means of pointer operations or native interop will result in Undefined Behavior. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| As of this writing there is no specific support for operating with incoherent memory, device memory or similar. Passing non-ordinary memory to the runtime by the means of pointer operations or native interop will result in Undefined Behavior. | |
| As of this writing there is no specific support for operating with incoherent memory, device memory or similar. Passing non-ordinary memory to the runtime by the means of pointer operations or native interop results in Undefined Behavior. |
Nit: The rest of the doc is present tense
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Style nit: The doc still has a mix of present and future tenses.
| Native-sized integer types and pointers have alignment that matches their size on the given platform. | ||
|
|
||
| ## Atomic memory accesses. | ||
| Memory accesses to *properly aligned* data of primitive types are always atomic. The value that is observed is always a result of complete read and write operations. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we also want to mention that reading and storing object references and unmanaged pointers is atomic?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Object references are always aligned. We can mention that (if it is already not mentioned).
But unmanaged pointers are just numbers with dereference operation defined. They may not be aligned, then reading/storing is not necessarily atomic.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, you mean the pointers themselves. When managed by the runtime the pointers and references are "properly aligned" and thus read/writes are atomic.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I meant the value of unmanaged pointer. Unmanaged pointer is not a primitive type, so the current wording does not cover it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it may be worth mentioning this. It follows from the "properly aligned" but pointers/references is a very common scenario.
| **Example:** memory accesses through pointers which are *not properly aligned* may be not atomic or cause faults depending on the platform and hardware configuration. | ||
|
|
||
| Although rare, unaligned access is a realistic scenario and thus there is some limited support for unaligned memory accesses, such as: | ||
| * `.unaligned` IL prefix |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: unaligned.
|
|
||
| * **Volatile reads** have "acquire semantics" - no read or write that is later in the program order may be speculatively executed ahead of a volatile read. | ||
| Operations with acquire semantics: | ||
| - IL load instructions with `.volatile` prefix when instruction supports such prefix |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: volatile.
|
https://github.com/dotnet/runtime/tree/main/docs/design/specs may be better location for this doc. It is not CoreCLR specific (botr is generally CoreCLR specific) and it augments ECMA spec (augments of ECMA spec live under docs/design/specs). |
| Currently supported implementations of CLR and system libraries make a few expectations about the hardware memory model. These conditions are present on all supported platforms and transparently passed to the user of the runtime. The future supported platforms will likely support these too as the large body of preexisting software will make it burdensome to break common assumptions. | ||
|
|
||
| * Naturally aligned reads and writes with sizes up to the platform pointer size are atomic. | ||
| That applies even for locations targeted by overlapping aligned reads and writes of different sizes. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might be worth calling out that this is explicitly different from Ecma 335, which only has this guarantee if all accesses are the same size.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is the same about dependent reads - ECMA does not guarantee it. I am not sure pointing to every small difference will make the document easier to read.
Not a lot of people would read ECMA spec prior reading this doc anyways.
| In the course of multiple releases CLR implementation settled around a memory model that is a practical compromise between what can be implemented efficiently on the current hardware, while staying reasonably approachable by the developers. This document rationalizes the invariants provided and expected by the CLR runtime in its current implementation with expectation of that being carried to future releases. | ||
|
|
||
| ## Alignment | ||
| When managed by CLR runtime, variables of built-in primitive types are *properly aligned* according to the data type size. This applies to both heap and stack allocated memory. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about members of structs?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fields are variables.
Regardless how you get something that has, for example, type int, it will be aligned accordingly. The runtime (i.e. JIT, GC, TypeSystem) will ensure alignment for all variables under runtime's control.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FieldOffsetAttribute could possibly introduce unaligned fields.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Arguably, by using FieldOffsetAttribute you are not letting the runtime to manage locations of the fields.
I will add a note about FieldOffsetAttribute as something that may cause unaligned variables.
|
|
||
| * **Volatile reads** have "acquire semantics" - no read or write that is later in the program order may be speculatively executed ahead of a volatile read. | ||
| Operations with acquire semantics: | ||
| - IL load instructions with `.volatile` prefix when instruction supports such prefix |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mention c# volatile keyword in here? Since most readers will commonly use that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Technically C# volatile belongs to C# spec, but it is common, so a few notes on that would be useful.
Adding "volatile" on a variable in C# is just a decoration that makes no difference to CLR, but it is a hint to C# compiler itself (and few other compilers) to emit reads and writes of that variable as volatile reads/writes - this is the part that CLR will honor.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah that would be great as an "aside" here, although it's not a CLR concept it is useful context
| - `System.Thread.VolatileWrite` | ||
| - Releasing a lock (`System.Threading.Monitor.Exit` or leaving a synchronized method) | ||
|
|
||
| * **.volatile initblk** has "release semantics" - the effects of `.volatile initblk` will not be observable earlier than the effects of preceeding reads and writes. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does any of this map to C# that can be described?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do not think C# emits these.
| The actual implementation may vary depending on the platform. For example interrupting the execution of every core in the current process' affinity mask could be a suitable implementation. | ||
|
|
||
| ## Synchronized methods | ||
| Synchronized methods have the same memory access semantics as if a lock is acquired at an entrance to the method and released upon leaving the method. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be good to reference the actual attribute
| - an optimizing compiler can omit the release semantics if it can prove that the instance is not shared with other threads. | ||
|
|
||
| ## Instance constructors | ||
| CLR does not specify any ordering effects to the instance constructors. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same comment wherever CLR is used, is Mono relevant?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think Mono is the same here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will be changing CLR reference to .Net, as discussed a few pages above. This doc tries to be applicable to all common implementations of .Net
(we will treat disagreements with the final form of this doc as bugs, if such disagreements are found)
| CLR does not specify any ordering effects to the instance constructors. | ||
|
|
||
| ## Static constructors | ||
| All side effects of static constructor execution must happen before accessing any member of the type. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Must or will?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
More precisely "will become observable not later than effects of accessing any member of the type..." or something like that.
Probably needs some re-wording.
Easy way to see it as if there is a lock that is taken when static constructor runs and releasing the lock is a release. The lock also guarantees that static constructor runs only once.
The implementation is not always a lock though, sometimes static constructors run at JIT time, NativeAOT may not run some static constructors at all and just use precomputed results embedded in the binary. The ordering result is the same.
|
|
||
| * Memory ordering honors data dependency | ||
| **Example:** reading a field, will not use a cached value fetched from the location of the field prior obtaining a reference to the instance. | ||
| (Some versions of Alpha processors did not support this, most current architectures do) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this relevant today?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, but people keep bringing this up as a concern. - "I heard that some CPUs do not honor data dependency" - Yes, some flavors of Alphas had that issue, possibly a mistake in the design, no, noone does that again.
| Synchronized methods have the same memory access semantics as if a lock is acquired at an entrance to the method and released upon leaving the method. | ||
|
|
||
| ## Object assignment | ||
| Object assignment to a location potentially accessible by other threads is a release with respect to write operations to the instance’s fields and metadata. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@danmoseley - here is somehting that could differ on Mono. Mono has a switch to turn this off. It is not the default behavior though, so I do not think it needs to be mentioned.
Co-authored-by: Jan Kotas <jkotas@microsoft.com> Co-authored-by: Dan Moseley <danmose@microsoft.com>
|
|
||
| ```cs | ||
|
|
||
| private object _lock = new object(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
private readonly object
Then it's consistent with
https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/statements/lock#guidelines
| As of this writing there is no specific support for operating with incoherent memory, device memory or similar. Passing non-ordinary memory to the runtime by the means of pointer operations or native interop results in Undefined Behavior. | ||
|
|
||
| ## Sideeffects and optimizations of memory accesses. | ||
| CLR assumes that the sideeffects of memory reads and writes include only changing and observing values at specified memory locations. This applies to all reads and writes - volatile or not. **This is different from ECMA model.** |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this forbid memory-mapped files mapped at multiple virtual addresses?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Runtime will assume that the memory is ordinary memory. If there is some additional semantics to the writes (as write to multimapped location will update other views), that is an Undefined Behavior. There can be no guarantees from the runtime on what will happen.
It may work as expected in simple cases, but the runtime would not help or prevent that.
|
|
||
| As a consequence: | ||
| * Speculative writes are not allowed. | ||
| * Reads cannot be introduced. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are "Speculative writes are not allowed" and "Reads cannot be introduced" really consequences of the above assumption, or additional guarantees made by the runtime?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is a design choice, but it follows from assuming that ordinary writes/reads can have observable sideeffects.
If you assume that a write may be visible from other threads (unless the location is known to be local to the current thread), then you can't replace
location = actualValue;with
location = someSpeculativeValue;
if (speculative value was wrong)
{
location = actualValue;
}Memory models that require only singlethreaded correctness allow this (ex: ordinary writes in C++), which is often unexpected and may result in subtle bugs in multithreaded scenarios.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So we are basically saying that the jit cannot introduce a race, but is allowed to remove one?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For ordinary memory accesses - yes. A program cannot condition its correctness on a nondeterministic race, that is not guaranteed to ever happen, so we can remove races. Adding a race would be observable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note that there are rare scenarios when a program explicitly wants to observe races - like making multiple or repeated reads of the same location and checking if the value has changed.
In such case it needs to use Volatile.Read, or a proposed compiler fence (#75874)
| - `System.Threading.Interlocked` methods | ||
|
|
||
| ## Process-wide barrier | ||
| Process-wide barrier has full-fence semantics with an additional guarantee that each thread in the program effectively performs a full fence at arbitrary point synchronized with the process-wide barrier in such a way that effects of writes that precede both barriers are observable by memory operations that follow the barriers. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe off-topic, but doesn't this also need compiler memory barriers to be effective?
e.g.
runtime/src/libraries/System.Threading/tests/InterlockedTests.cs
Lines 677 to 682 in 118a162
| // Make sure that the compiler won't reorder the read with the above write by wrapping the read in no-inline method. | |
| // RyuJIT won't reorder them today, but more advanced optimizers might. Regular Volatile.Read would be too big of | |
| // a hammer because of it will result into memory barrier on ARM that we do not need here. | |
| // | |
| // | |
| if (VolatileReadWithoutBarrier(ref _current) == entry) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. Process-wide barrier is typically paired with a compiler barrier. A not-inlineable getter or a volatile read can work as such, but maybe it is time to have an official compiler barrier helper.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Re: #75874
| // - reads cannot be introduced, thus localObj cannot be re-read and become null | ||
| // - publishing assignment to obj will not become visible earlier than write operations in the MyClass constructor | ||
| // - indirect accesses via an instance are dependent reads, thus we will see results of constructor's writes | ||
| System.Console.WriteLine(localObj.ToString()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
localObj.ToString() might not be a good example, because .ToString() could possibly read static fields that were written by the constructor (without using volatile). Static field reads are not dependent reads, and do not have the ordering guarantee. Probably better to explicitly read some instance field here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The example is expected to work correctly even if .ToString accesses string's static fields.
it would be highly inconvenient if it did not work as caller does not often know these details.
Static field reads are not dependent reads
The rule about static constructors above tells that the cctor runs before any member is accessed and it would be useless to guarantee that static constructor "runs before" accessing without implying that the access will see the complete results.
The mechanism will vary and often the results of the static constructors will be computed well in advance before the actual access (at JIT time or Load time).
As a last resort this will be a dependent read from the type-attached storage, and the type would be derived from the instance, so there is a dependency chain from the instance to the static data.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't mean static constructor. Consider
sealed class C
{
//There may only be one instance, so why not make every field static?
static string name;
private C()
{
name = "My Name";
}
public override string ToString()
{
return name;//should always return "My Name"
}
static readonly object lockObj = new object();
static C c;
//Thread 1 and 2
public static void PrintSingleton()
{
if (c is null)
{
lock (lockObj)
{
c ??= new C();
}
}
Console.WriteLine(c.ToString());
}
}There is no dependency between reading C.c in C.PrintSingleton() and reading C.name in C.ToString(). Both threads may not print "My Name".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That is just a write to some shared memory from the constructor. It could be a static variable in another class or unmanaged memory. Constructor can do many things. It can also launch a thread that will write to statics or it can do c = this or lockObj = null.
Having a constructor with sideeffects other than constructing and initializing the instance would make the type a poor choice for the singleton pattern.
The sample here is not about that, but about guarantees provided by the runtime.
I will add a definition of a simple MyClass so there is no confusion about what constructor does.
| } | ||
| } | ||
|
|
||
| return _inst; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It may be better to use volatile read here to ensure side effects of the constructor, e.g. static field writes, are visible. LazyInitializer currently also uses volatile reads.
runtime/src/libraries/System.Private.CoreLib/src/System/Threading/LazyInitializer.cs
Lines 247 to 248 in c59b517
| public static T EnsureInitialized<T>([NotNull] ref T? target, [NotNullIfNotNull(nameof(syncLock))] ref object? syncLock, Func<T> valueFactory) where T : class => | |
| Volatile.Read(ref target!) ?? EnsureInitializedCore(ref target, ref syncLock, valueFactory); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No need for volatile reads. Any data written directly or indirectly prior to the publishing of the instance will be visible via the instance. (see comments about the statics in the previous comment)
|
|
||
| public MyClass GetSingleton() | ||
| { | ||
| MyClass localInst = _inst; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It may be better to use volatile read here. See above for reasons.
|
|
||
| As a consequence: | ||
| * Speculative writes are not allowed. | ||
| * Reads cannot be introduced. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reads cannot be introduced
Does this mean the following RyuJit behavior is a bug?
[MethodImpl(MethodImplOptions.NoInlining)]
private static int Problem(int* p)
{
var b = *p == 1;
var c = b;
if (b)
{
JitUse(c);
return 2;
}
return 1;
}
[MethodImpl(MethodImplOptions.NoInlining)]
public static void JitUse<T>(T arg) { }IN000a: 000000 sub rsp, 40
G_M51150_IG02: ; offs=000004H, size=000DH, bbWeight=1 PerfScore 8.25, gcrefRegs=00000000 {}, byrefRegs=00000000 {}, BB01 [0000], byref, isz
IN0001: 000004 xor eax, eax
IN0002: 000006 cmp dword ptr [rcx], 1 ; The original load
IN0003: 000009 sete al
IN0004: 00000C cmp dword ptr [rcx], 1 ; The duplicated load
IN0005: 00000F jne SHORT G_M51150_IG05
G_M51150_IG03: ; offs=000011H, size=000DH, bbWeight=0.50 PerfScore 1.75, gcrefRegs=00000000 {}, byrefRegs=00000000 {}, BB02 [0001], byref
IN0006: 000011 mov ecx, eax
IN0007: 000013 call [RyuJitReproduction.Program:JitUse(bool)]
IN0008: 000019 mov eax, 2
G_M51150_IG04: ; offs=00001EH, size=0005H, bbWeight=0.50 PerfScore 0.62, epilog, nogc, extend
IN000b: 00001E add rsp, 40
IN000c: 000022 ret
G_M51150_IG05: ; offs=000023H, size=0005H, bbWeight=0.50 PerfScore 0.12, gcVars=0000000000000000 {}, gcrefRegs=00000000 {}, byrefRegs=00000000 {}, BB03 [0002], gcvars, byref
IN0009: 000023 mov eax, 1
G_M51150_IG06: ; offs=000028H, size=0005H, bbWeight=0.50 PerfScore 0.62, epilog, nogc, extend
IN000d: 000028 add rsp, 40
IN000e: 00002C ret (This particular example needs DOTNET_JitNoCSE=1 to be reproduced)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If DOTNET_JitNoCSE is a supported scenario, then this would seem like a bug.
"b == c" in the source and compiler introduced a race. @AndyAyersMS
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
JitNoCSE was just to create an easy and small sample; without it the compiler would have folded the duplicated load back, of course, that's not guaranteed in the general case.
Opened #75916 to track this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, we should avoid duplicating reads like this.
|
This pull request has been automatically marked |
|
@VSadov I think it would be a good idea to merge this doc even though if it is not 100% finished. Could you please mark the few places with open questions, and address any remaining feedback? |
|
@jkotas Yes, I was going to ask the same question. I think there are couple questions (like provide some details/examples in couple places), but otherwise there is nothing major and actionable. I will go through the feedback once more, address remaining questions and I think we can wrap this up. |
|
@jkotas - I think I addressed all the remaining actionable feedback. |
|
I believe that we have identified a few open questions where we are not sure about the current CoreCLR JIT being compliant with what's written in the doc and we are not sure about the cost to make it compliant. (@markples has a work item to investigate one of them.) Can we mention them in the doc? |
|
@jkotas There was a concern about compiler optimization honoring ordering of data-dependent reads (and writes to object fields to a lesser degree). We expect that from the hardware (for writes we insert fences if needed). The question was whether compiler itself may violate such ordering in some optimizations and whether it is costly to prevent that if it does. I think the concern was mostly about reads as writing has obvious sideeffects, which limits what optimizations can do. Preliminary conclusion was that compiler honors data dependency because the internal representations are tree-based. If that does not hold, we will fix the compiler, ... or the document. I hope it just holds. At very least it would be unintuitive. |
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
We should also make sure that this holds for Mono LLVM-based codegen. |
|
Could you please include a link in the doc? |
jkotas
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you!
|
Thanks!!! |
|
|
||
| There was a lot of ambiguity around the guarantees provided by object assignments. Going forward the runtimes will only provide the guarantees described in this document. | ||
|
|
||
| _It is believed that compiler optimizations do not violate the ordering guarantees in sections about [data-dependent reads](~#data-dependent-reads-are-ordered) and [object assignments](~#object-assignment), but further investigations are needed to ensure compliance or to fix possible violations. That is tracked by the following issue:_ https://github.com/dotnet/runtime/issues/79764 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The given links lead to HTTP 404.
https://github.com/dotnet/runtime/blob/main/docs/design/specs/~
Is the resulting url.
Edit: created #79785 to fix this.
A document describing memory model of .NET runtimes.
Fixes #63474