Allocators and thread safety (and not only)

I assume you refer to this:

Consider that we could do something like:

const std = @import("std");
const Allocator = std.mem.Allocator;

const ThreadSafeAlloc = struct {
    allocator: Allocator,
    pub fn dupe(
        self: ThreadSafeAlloc,
        comptime T: type,
        m: []const T,
    ) std.mem.Allocator.Error![]T {
        return self.allocator.dupe(T, m);
    }
    pub fn free(self: ThreadSafeAlloc, memory: anytype) void {
        self.allocator.free(memory);
    }
    // And mimic all other methods of Allocator in the same way,
    // e.g. accomplished through comptime reflection, i.e. generate
    // ThreadSafeAlloc with a function.
};

fn foo(alloc: ThreadSafeAlloc) error{OutOfMemory}![]u8 {
    // Let's assume we need a thread-safe allocator here.
    return try alloc.dupe(u8, "Please free me!");
}

pub fn main() !void {
    const alloc = ThreadSafeAlloc{ .allocator = std.heap.smp_allocator };
    const message = try foo(alloc);
    std.debug.print("{s}\n", .{message});
    alloc.free(message);
}

P.S.: Actually I’m not sure if current Zig allows this sort of comptime reflection?

I know you’re focused on the specific issue of thread safety with allocators, but this is just one possible attribute that one would like to have embodied in the type system and checked at compile time. I’d want to see a more general solution, but this example is perfectly good motivating example for the need.

We can embed anything at all into a type (declarations) and check it however we need to at comptime, by writing more Zig.

I have yet to see a problem this can’t solve, but you need to be able to get the type with @TypeOf, which won’t work on an *anyopaque. So we can use it on β€œanything which can make an Allocator” but not on Allocator itself.

Some people (not to say you’re one of them!) think that this β€œdoesn’t count”, like if user code writes its own type check that’s somehow not part of the type system. If it doesn’t compile then it’s a type failure, the way I see it. Comptime is just so much more powerful than the usual way of doing this that it doesn’t look like a type check.

1 Like

So the trick is how to arrange the code that does the type check. I can imagine either the library provides a function to generate compatible allocators, or a allocator registration function. These do the type check and save debug-only data to check that a passed allocator is one that has been registered.

For me, I don’t mind documenting the constraints and it’s up to users of my library to RTFM. But I understand in some environments that level of trust in your clients is a no-go, or the documentation may not be translated into a language the users understand, for example.

2 Likes

If the code can reach the type, you’re golden. The callee is in complete control: anything you can determine about the specifics of the type, and that’s a great deal, can be turned into a compile failure with an informative message about what’s missing. If the caller wants to use that code, they can get that stuff right or it won’t compile. I suppose they could fork it, but let’s make the simplifying assumption that these constraints are load-bearing.

The type-erased vtable pattern poses a problem for that approach: you can’t get the type given the interface. A severe problem? Well, no. But not a made-up one.

Documentation can go a long way. But the best documentation of type-level contract violation is an informative compile error. Otherwise it’s either a mysterious, or hopefully informative, runtime failure: those are not as nice.

I have simple solution -

I DON’T NEED YOUR ALLOCATOR!!!

Usage of caller’s allocator it’s just convention/idiom
It is not forced by compiler/formatter.

So in my code and tests I will use DebugAllocator, it’s thread-safe by default and has process life cycle.

But my code will run (I hope) in someone’s process…

While you are correct that one can implement their own ad-hoc type system, you do lose the benefit that types are checked automatically. So if I forget to run my own type-checking code the program might crash at runtime, which would be impossible if certain things were expressable in the type system. So while I understand your point I do not consider such compile-time checks as part of the type system, because they might cause runtime failures whereas type errors are caught during compilation. Having some way to β€œinfuse” compile-checks into a type would be awesome.

Regarding the points about separating storage from strategies, that does remind me of the little I know about C++'s polymorphic memory resources, maybe something for some smart person to look into?Otherwise I think the current model with the Allocator interface works well enough for me.

We have this, it’s functions which return types and take type parameters.

If you want to use the strategy on anytype generic functions, then you do have to narrow things functionally. You can write one function for this purpose and reuse it everywhere which makes sense.

A programmable type system means by definition that it’s possible to make type-level logical errors. That’s because it’s strictly more powerful than weaker approaches to the problem.

malloc(3) β€” Linux manual page has section β€˜Attributes’:

   For an explanation of the terms used in this section, see
   attributes(7).
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ Interface                            β”‚ Attribute     β”‚ Value   β”‚
   β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
   β”‚ malloc(), free(), calloc(),          β”‚ Thread safety β”‚ MT-Safe β”‚
   β”‚ realloc()                            β”‚               β”‚         β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Two other β€˜attributes’ of malloc are implicit:

  • used heap memory persists for the duration of the application’s runtime

  • free - releases memory to heap for further reuse

    In the same manner we can say that Zig replacement for malloc should also to have these attributes:

  • mt-safe
  • application runtime duration
  • reusing

We can add bool property to Allocator:

const Allocator = @This();
........................................
ptr: *anyopaque,
vtable: *const VTable,
mallocReplacement: bool = false, 

DebugAllocator has all these attributes, it may be used as malloc replacement.


pub fn DebugAllocator(comptime config: Config) type {
    return struct {
    .......................................
    .......................................

        pub fn allocator(self: *Self) Allocator {
            return .{
                .ptr = self,
                .mallocReplacement = true,
                .vtable = &.{
                    .alloc = alloc,
                    .resize = resize,
                    .remap = remap,
                    .free = free,
                },
            };
        }


Minimal changes

The documentation you quote for these allocators is just that: documentation. It isn’t something anywhere in the type system related to these functions.

1 Like

It’s exactly the reason of adding Attributes to linux man pages.

For now there are only 2 attributes:

  • MT-Safe
  • MT-Unsafe

I gave up, but this post forced me return to the discussion.

Question:

Referenced code from std [Thread] Pool

And an answer

Do you see the problem here?

It’s the same problem, as problem of ThreadSafeAllocator - if allocator is not thread-safe, any wrapping with lock/unlock will not prevent non thread-safe usage.

With my proposal std code can check thread-safety of allocator and return error.

I know that

Compile errors are better than runtime crashes.

But

Runtime crashes are better than bugs.