Critiques
-
(2025-12-12) I highly recommend checking out this discord thread in the Odin server, where there were 270+ answers from passionate people about the topic.
-
Many defended context, where some admitted not enjoying the experience of having to work with it. Overall, I learned a lot and changed my mind about
user_ptrand user_index, but couldn’t wrap my head around thecontext.allocator/context.temp_allocator, as I see this as a solution to the problem of implicit allocations in the language, as well ascontext.logger/context.assertion_failure_proc/context.random_generator, as I do believe they would be a better fit as thread-local or global variables. -
One day after having this discussion (2025-12-13) I decided to create my own fork of Odin and experiment with different design choices. The README.md of the repo goes on explaining the changes I made to the language and what the target of this fork is; it's very exploratory. I cover a lot of the discussed topics in my fork, testing approaches for the designs I've proposed.
-
(2026-01-22) Quite update: I'm very much enjoying working with my fork, no complains so far, I feel much more confident with the language.
-
-
This article was written right after the discussion in the discord thread.
-
"You could argue that it is “better” to pass allocators around explicitly, but from my own experience in C with this exact interface (made and used well before I even made Odin), I found that I got in a very very lazy habit of not actually passing around allocators properly. This overly explicitness with a generalized interface lead to more allocation bugs than if I had used specific allocators on a per-system basis."
-
context.allocatoris used to solve a problem that his way of programming created. If the language was not designed around implicit and unsafe allocations withallocator :=, then this wouldn't be necessary. If, for example,make()required an allocator, whilecontext.allocatordidn't exist, you wouldn't have any other choice but to use an allocator passed as an argument in the procedure.
-
-
My Rant
-
(2025-12-12)
-
I'm not a fan of context at all .
-
To even print you need to have a
contextdefined. -
This doesn't compile:
// "contextless" procedure { fmt.printfln("TEST") } -
This works just fine .
// "contextless" procedure { context = runtime.default_context() context.allocator = mem.panic_allocator() context.temp_allocator = mem.panic_allocator() fmt.printfln("TEST") } -
It really doesn't matter if everything is not even initialized, or the allocators are defined to
panic_allocator. -
Let's go through the checklist:
-
Why do I NEED to define a
context?-
Because the proc is defined with the
:: proc()calling convention, indicating that it NEEDS acontext.
-
-
Why does it NEED a
context?-
Because internally
fmt.printflncallswprintf, which usesassert, andassertrequires acontext. -
A LOT of the other procedures inside the chain are defined with the
:: proc()calling convention without even needing it. -
In this chain, ONLY
assertrequirescontext, and no other procedure.
-
-
Why does
assertNEEDcontext?-
So it can "print as user configured" by the
context.assertion_failure_proc.
-
-
Why do we NEED a
context.assertion_failure_proc?-
We don't.
-
The purpose of this assertion procedure is to "use the same assert procedure as configured by the user":
assert :: proc(condition: bool, message := #caller_expression(condition), loc := #caller_location) { if !condition { @(cold) internal :: proc(message: string, loc: Source_Code_Location) { p := context.assertion_failure_proc if p == nil { p = default_assertion_failure_proc } p("runtime assertion", message, loc) } internal(message, loc) } } -
But the idea doesn't actually work a lot of the time, as this happens:
-
base:runtime/default_temp_allocator_arena.odin-
This is not using the "assertion procedure" defined by the USER, just the default one.
context = default_context() context.allocator = allocator mem_free(block_to_free, allocator, loc) -
-
base:runtime/print.odin-
This is not using the "assertion procedure" defined by the USER, just the default one.
println_any :: #force_no_inline proc "contextless" (args: ..any) { context = default_context() loop: for arg, i in args { assert(arg.id != nil) if i != 0 { print_string(" ") } print_any_single(arg) } print_string("\n") } -
-
-
There are a lot of uses of
runtime.default_context()in thebaseandcorelibrary, while it's also suggested to useruntime.default_context()for"c"and"contextless"calling conventions. Every time you do it, you lose the reason forcontextbeing invented in the first place. -
"The main purpose of the implicit context system is for the ability to intercept third-party code and libraries and modify their functionality. One such case is modifying how a library allocates something or logs something" - implicit context system .
-
Expect when this is not the case.
-
-
Could this function be
contextless?-
Yes.
-
assert_contextlessalready solves this by:assert_contextless :: proc "contextless" (condition: bool, message := #caller_expression(condition), loc := #caller_location) { if !condition { @(cold) internal :: proc "contextless" (message: string, loc: Source_Code_Location) { default_assertion_contextless_failure_proc("runtime assertion", message, loc) } internal(message, loc) } } -
But if you want customization defined by the user, just:
assert_contextless :: proc "contextless" (condition: bool, message := #caller_expression(condition), loc := #caller_location) { if !condition { @(cold) internal :: proc "contextless" (message: string, loc: Source_Code_Location) { if global_assertion_failure_procedure_defined_by_the_user != nil { global_assertion_failure_procedure_defined_by_the_user("runtime assertion", message, loc) } else { default_assertion_contextless_failure_proc("runtime assertion", message, loc) } } internal(message, loc) } } main :: proc() { runtime.global_assertion_failure_procedure_defined_by_the_user = my_assertion_procedure }-
Different from the way
contextis used, in this case you ACTUALLY get the user-defined assertion procedure, with a fallback if not defined. -
With
contextthe code may or may not use the assertion procedure defined by you, but with this code above, your assertion procedure will ALWAYS be used, WITHOUT NEEDING A CONTEXT!!
-
-
-
-
Fun fact,
assertdoesn't actually care if thecontext.assertion_failure_procwas defined. If not defined, it just falls back to thedefault_assertion_failure_proc:assert :: proc(condition: bool, message := #caller_expression(condition), loc := #caller_location) { if !condition { @(cold) internal :: proc(message: string, loc: Source_Code_Location) { p := context.assertion_failure_proc if p == nil { // <-- note here p = default_assertion_failure_proc } p("runtime assertion", message, loc) } internal(message, loc) } } -
So the first snippet is technically the same as the one below:
{ context = {} fmt.printfln("TEST") } -
"Ok, but in this case the allocators are not
panic_allocators, just "nil" ({ data = nil, procedure = nil }), so this might crash if you try to allocate right?"-
Nope. If
allocator.procedure == nil, it just doesn't allocate without returning any errors. You might not even realize you don't have acontext.allocatorandcontext.temp_allocatordefined. You'll get a nil pointer without returned errors. The code just silently allows this.mem_alloc_bytes :: #force_no_inline proc(size: int, alignment: int = DEFAULT_ALIGNMENT, allocator := context.allocator, loc := #caller_location) -> ([]byte, Allocator_Error) { assert(is_power_of_two_int(alignment), "Alignment must be a power of two", loc) if size == 0 || allocator.procedure == nil { return nil, nil } return allocator.procedure(allocator.data, .Alloc, size, alignment, nil, 0, loc) } -
This is a whole different discussion about explicitness, but I thought I mentioned.
-
-
My point is:
-
A lot of procedures REQUIRE
contextwhen they shouldn't. They don't actually need it and it just creates bloated and visually confusing code. -
I believe that ALL fields from the
contextcould be defined as thread local global variables customizable by the user, and:: proc()should becontextlessby default, while having the wholecontextsystem removed. -
"But what about
context.allocatorandcontext.temp_allocatorthat are so used around all the libs?"-
Instead of
context.allocator, just useruntime.allocator. -
Instead of
context.temp_allocator, just useruntime.temp_allocator. -
Both
runtime.allocatorandruntime.temp_allocatorwould be allocators automatically initiated right before_startup_runtime(), just likeruntime.default_context()does, but this time without compromising all libraries by demanding thatcontextbe used.
-
-
"What about logger??"
-
Same idea, instead of
context.logger, just uselog.logger.
-
-
"What if I want something only for a scope, to then go back to the previous thing?"
context.allocator = runtime.heap_allocator() context.user_index = 456 { context.allocator = my_custom_allocator() context.user_index = 123 } assert(context.user_index == 456)-
First off, this is weird. I don't think it's obvious for anyway at first how
context.user_index == 456when it was just defined as1232 lines above. -
Secondly, if you really want a scope thing, just do:
allocator := runtime.heap_allocator() { scope(&allocator, {}) assert(allocator == {}) } assert(allocator == runtime.heap_allocator()) @(deferred_out=_scope_thingy_end) scope :: proc(old_value: ^mem.Allocator, new_value: mem.Allocator) -> (old_value_out: ^mem.Allocator, previous_value: mem.Allocator) { previous_value = old_value^ old_value^ = new_value return old_value, previous_value } _scope_end :: proc(old_value: ^mem.Allocator, previous_value: mem.Allocator) { old_value^ = previous_value }-
It would be useful if we could use polymorphic parameters with
deferred_out, but I don't really mind as I never usedcontextthis way anyway. -
I mean, it's really just an auxiliary variable, it shouldn't be that big of a problem. At least the variable changing when exiting the scope is much more obvious than the implicit way
contextdoes.
-
-
"Finally, what about cache locality?"
-
I'm not completely sure about this one. I imagine that many of the fields inside context wouldn't care that much, as there's an indirection inside every allocator, logger, etc, but that would have to be profiled. Anyway, I would imagine it pays off for not having to carry a ~196 bytes struct around for every function call.
-
Laytan:
-
We've done a test to determine if thread local context is faster than passing it as a param and found the difference negligible.
-
-
-
Usages
-
This is the usages I could find by
ctrl+shift+Fon the whole Odin repository (base, core, vendor):Context :: struct { allocator: Allocator, // Everywhere. temp_allocator: Allocator, // Everywhere. assertion_failure_proc: Assertion_Failure_Proc, // Used in `assert`, `panic`, `ensure`, `unimplemented`. // Used in `fmt` as: `assertf`, `panicf`, `ensuref`. // Used in `log` as: `assert`, `assertf`, `ensure`, `ensuref`. random_generator: Random_Generator, // Used in `math/rand`, `encoding/uuid` logger: Logger, // `core:log` is imported for `core:text/table`, `vendor:fontstash`, `vendor:nanovg/gl`. // `context.logger` is used directly only once in `core:mem` (doesn't make any sense, tbh). user_ptr: rawptr, // Not used anywhere. user_index: int, // Not used anywhere. _internal: rawptr, // Not used anywhere, except in 1 Cpp script. }
context.allocator
-
For “general” allocations, for the subsystem it is used within.
-
Is an OS heap allocator .
context.temp_allocator
-
For temporary and short lived allocations, which are to be freed once per cycle/frame/etc.
-
Assigned to a scratch allocator (a growing arena based allocator).
Init
-
base:runtime->core.odin
@private
__init_context :: proc "contextless" (c: ^Context) {
if c == nil {
return
}
// NOTE(bill): Do not initialize these procedures with a call as they are not defined with the "contextless" calling convention
c.allocator.procedure = default_allocator_proc
c.allocator.data = nil
c.temp_allocator.procedure = default_temp_allocator_proc
when !NO_DEFAULT_TEMP_ALLOCATOR {
c.temp_allocator.data = &global_default_temp_allocator_data
}
when !ODIN_DISABLE_ASSERT {
c.assertion_failure_proc = default_assertion_failure_proc
}
c.logger.procedure = default_logger_proc
c.logger.data = nil
c.random_generator.procedure = default_random_generator_proc
c.random_generator.data = nil
}
-
Using
context = {}-
(2025-12-12) I'm not sure how this looks as of today.
Threading
-
A new context is created using
runtime.default_context()if not context is specified when callingthread.create_and_start. -
The new context will maybe clean up its
context.temp_allocator.-
Tetra, 2023-05-31:
-
If the user specifies a custom context for the thread, then it's entirely up to them to handle whatever allocators they're using.
-
-
// core:thread
_select_context_for_thread :: proc(init_context: Maybe(runtime.Context)) -> runtime.Context {
ctx, ok := init_context.?
if !ok {
return runtime.default_context()
}
/*
NOTE(tetra, 2023-05-31):
Ensure that the temp allocator is thread-safe when the user provides a specific initial context to use.
Without this, the thread will use the same temp allocator state as the parent thread, and thus, bork it up.
*/
when !ODIN_DEFAULT_TO_NIL_ALLOCATOR {
if ctx.temp_allocator.procedure == runtime.default_temp_allocator_proc {
ctx.temp_allocator.data = &runtime.global_default_temp_allocator_data
}
}
return ctx
}
// core:thread
_maybe_destroy_default_temp_allocator :: proc(init_context: Maybe(runtime.Context)) {
if init_context != nil {
// NOTE(tetra, 2023-05-31): If the user specifies a custom context for the thread,
// then it's entirely up to them to handle whatever allocators they're using.
return
}
if context.temp_allocator.procedure == runtime.default_temp_allocator_proc {
runtime.default_temp_allocator_destroy(auto_cast context.temp_allocator.data)
}
}
// core/thread/thread_windows.odin:41 / core/thread/thread_unix.odin:54
_create :: proc(procedure: Thread_Proc, priority: Thread_Priority) -> ^Thread {
// etc
{
context = _select_context_for_thread(init_context)
defer {
_maybe_destroy_default_temp_allocator(init_context)
runtime.run_thread_local_cleaners()
}
t.procedure(t)
}
//etc
}