No default safety with allocators?

Hello :wave: Enjoying Zig for a few days now, love it so far, but have a question regarding allocators. Using v0.12.0 on osx and Ubuntu.

Reading through the docs and going through the zig guide to get started I noticed that most examples use the page_allocator by default and the testing allocator for tests. I also read that one should run without optimizations while developing to get full safety by default and be notified when I forget to deinit() or free() for example.

Now I noticed that when I use the page allocator and remove the deinit(), then use zig run ... the program just executed normally, leaking the memory without any errors. When I switch to GPA, I get the expected leak error, but only when I check the deinit() result for .leak and @panic manually. I was under the impression from docs that this would be default behaviour for allocators without explicit optimization flags used.

Now my actual questions:

  • is this expected behaviour?
  • why are all examples using the page allocator?
  • am I expected to write functions that take an allocator as dependency so I can switch it out in dev vs. Prod?
    • is there a better way?
    • Edit: forgot to mention that another option I am trying is comptime selecting the allocator I want to build in.
  • what are the experiences and best practices here so far?

Note: this is not a rant, I love what zig is doing and I am genuinely interested.

2 Likes

I’m by no means an expert but I think when reference to “Safety by default” it is referencing things like out of bound access checks for arrays. Memory Leaks are not generally considered safety issues. Even Rust allows you to leak memory from “safe” contexts.

is this expected behaviour

I Think so. The Testing Allocator (IIRC) should do memory leaking reporting by default.

Why are all examples using the page allocator?

I Don’t know. Most of what I’ve read is that the page Allocator is not great to use directly, but it is good for using as a backing allocator (Like for Arenas or the GPA)

am I expected to write functions that take an allocator as dependency so I can switch it out in dev vs. Prod?

The rule In general is: For libraries if you need an allocator you should have it passed into the function so that users can pick what allocator to use. When writing executables, then there is more leeway in using “hidden” allocations, as the code is not intended to be “reused” by others.

6 Likes

Most of the time, they are bad examples.

If you are developing or you are learning zig use the GeneralPurposeAllocator or the std.testing.allocator when testing (testing allocator is an instance of the general purpose allocator). The reason is that they are designed to catch errors.

Choosing an Allocator in zig documentation advises when to use each allocator.

6 Likes

Thanks for the input on this, very much appreciated!

I could use the GPA as a base for an Arena and flip the safety flag based on if I am doing a dev/debug build or prod build. However, I haven’t found out yet how I can check at comptime what I am building… Should I inject something via the build.zig or is there a built in way to know what optimization is being used?

When you look at the documentation gpa Config and std.debug.runtime_safety you can see that the gpa automatically switches safety checks on and off, via its default value:

safety: bool = std.debug.runtime_safety

and

pub const runtime_safety = switch (builtin.mode) {
    .Debug, .ReleaseSafe => true,
    .ReleaseFast, .ReleaseSmall => false,
}

If you are using an arena you won’t get messages for memory leaks, because the arena handles freeing those (unless you leak the arena itself). So if you are developing code that is meant to be used without an arena, then I would avoid the arena, because it could hide the memory leaks.

Then only use an arena if you either have tested that there aren’t leaks, or if you have decided that you always want to use an arena and have it handle the intentional leaks. (Because with an arena you don’t need to free, but you need to manage the life time and potentially the resetting of the arena)

7 Likes

Oohhhh! Awesome, I missed that , was wondering why that wouldn’t be the case already. Makes sense. Thanks!

1 Like

Every time an example uses the page allocator, a kitten cries somewhere in the world… :crying_cat_face: See Over-Allocation with the Page Allocator .

In Debug and ReleaseSafe when I use the GPA like this, I always get an error if there are memory leaks:

    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

This has many benefits. As you mentioned, it gives you flexibility on which allocation strategy to use by switching the allocator. It also lets the reader of your code easily see where allocations are going to happen just by looking at the function signature. If it takes an allocator, an allocation will probably occur.

2 Likes

@dude_the_builder I have all your videos on my watch list :slight_smile:

I opened an issue on the repo for zig.guide to change the examples to use the GPA, as this is what a lot of people are probably stumble upon first, as did I. If that is a good idea I’ll try to find some time to create PR to change the examples.

2 Likes

As for checking the build mode…

I searched for how to do conditional compilation but the results I found predate 0.12.0 (or some other ver?) and did something like exe.addOptions(..) which no longer works. I think the right way is to do exe.root_module.addOptions(..).

I built a minimal example and got it working. I named some variables oddly to better understand where they show up.

In build.zig:

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // we  can import these, eg:
    // const opts = @import("build_options");
    // if (.Debug == opts.optimize_mode) { ... }
    const options_A = b.addOptions();
    options_A.addOption(
        usize,
        "hello_A",
        b.option(usize, "hello_B", "number of hellos") orelse 1,
    );
    options_A.addOption(
        std.builtin.OptimizeMode, // an enum; .Debug, .ReleaseSafe, .ReleaseFast, .ReleaseSmall
        "optimize_mode",
        optimize,
    );

    const exe = b.addExecutable(.{
        .name = "build_opts",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });
    // here
    exe.root_module.addOptions("options_B", options_A);

    b.installArtifact(exe);
    ...

In main.zig:

const std = @import("std");
const opts = @import("options_B");

pub fn main() !void {
    if (.Debug == opts.optimize_mode) {
        std.debug.print("All your {s} are belong to us.\n", .{"codebase"});
    }

    const stdout_file = std.io.getStdOut().writer();
    var bw = std.io.bufferedWriter(stdout_file);
    const stdout = bw.writer();

    for (0..opts.hello_A) |_| {
        try stdout.print("Hello, World!\n", .{});
    }

    try bw.flush(); // don't forget to flush!
}

‘hello_B’ is the cli arg name, so you’d use -Dhello_B=5 for example, but ‘hello_A’ is the key name in the options.

Is this a good way of checking for release modes / build options?

Edit:
Info with correct method: options-for-conditional-compilation

Old SO answer with old method but still working method of adding options to the command line: compile time

1 Like

The same way the standard library does it:

const builtin = @import("builtin");
// builtin.mode
// if(builtin.mode == .Debug) { ... }

Or within the build zig:

const optimize = b.standardOptimizeOption(.{});
...
if(optimize == .Debug) { ... }

Adding these custom build options is only necessary if you want to do things which aren’t the default way of doing things, otherwise standardOptimizeOption returns the build mode and in your build.zig you usually pass that along to the modules you are using.

In zigraylib/build.zig at 9cb65cbf054c7a64f291c7aee28c23869721bfc8 · SimonLSchlee/zigraylib · GitHub I used a custom build option that can be used to override the build mode for raylib, but it defaults to the build mode returned by standardOptimizeOption

4 Likes

Thank you, that’s much easier. I remembered seeing a simpler way, of course it was all in the manual. Detecting-Test-Build, Compile-Variables.

2 Likes

There is also this tipp @dimdin showed, that allows you to easily look at the generated builtins for specific options: Invalid ELF header when targeting Linux specifically - #2 by dimdin

2 Likes

Oh wow, it is actually in the docs. The builtin.is_test is dangerous though imho, as the usage would imply different behavior in tests than in a prod build. I can see the use case for me as well though to use a different allocator for tests.

I use it in a helper function logError to direct a formatted message to std.log.err, except in tests, where it uses std.log.warn. A test which logs to std.log.err is considered a failing test, so this is basically a workaround for a quirk of the Zig test suite.

A lot of good tools are also dangerous when you’re doing low-level programming. The documentation might want to point out that lazy compilation means that anything which is only used in a test will not become part of the binary except in a test build, so users aren’t tempted to redundantly block out e.g. test data using builtin.is_test.

Or maybe that’s something users should be expected to learn. If you don’t thoroughly understand that Zig does lazy compilation, you’re going to have a bad time.

1 Like

Can you point out where the docs use page_allocator? I’d like to fix it, but I don’t see such an example in the language reference. I’m not sure what other docs you are referring to.

A lot of blog posts use page_allocator for parsing arguments:

const allocator = std.heap.page_allocator;
const file_path = try args.next(allocator);

zig.guide on allocators starts with:

The most basic allocator is std.heap.page_allocator

and gives us the first example:

test "allocation" {
    const allocator = std.heap.page_allocator;
    ...

I have concluded that:

  • Given the many options that people have with allocators they are confused, at least until they learn all the available allocators.
  • Most people use std.heap.page_allocator because it is one liner, it is easy to find and have clear usage.

A clear direction in tutorials, like “use GeneralPurposeAllocator unless you are testing”, might help.

An idea, that might help, is to add the GPA initialization sequence in the main.zig that is generated by zig init.

    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();
1 Like

zig.guide is a third party project, unaffiliated with the Zig project. It also explains pretty clearly what page allocator does before showing the code example. That page seems fine to me.

I think your suggestion would lead people to use general purpose allocation unnecessarily when that is often the wrong tool for the job. It’s a C++ way of thinking about things that often needs to be unlearned by Zig newcomers.

1 Like

I love your perspective. I’m sure you’re right about C/C++/rust newcomers.

I was thinking people coming from garbage collected languages. They are overloaded with concepts. I believe it is better to introduce them to the allocators usage using GPA and later give them the full picture.

1 Like

I think my perception was a little bit skewed after many hours playing with the concepts. It’s actually just used in the examples on zig.guide and not even that much there. Sorry for the confusion and thanks for taking a newbie seriously here.

As a newcomer I think the concept of the allocators should just be earlier in the guide and as it is a third party project I will point it out over there. Many people stumble upon that I guess and take it as an official guide. The guide really helps though.

I am coming from C++ originally but have been with node.js for way too long and it got me really confused. I think José aka dude_the_builder is doing an amazing job of explaining a lot of the concepts.

3 Likes