1. 23
    1. 12

      The responses to this are also somewhat well known:

      1. That competent (or sufficiently meticulous) programmers can avoid memory unsafety by sticking to best practices and using ample unit testing, sanitizers, and fuzzers;
      2. That many (but not all) unsound patterns can be detected with (bounded) model checkers, abstract interpretation, symbolic execution, or other forms of static analysis;
      3. That “safe” languages are (roughly) as vulnerable to logic, serialization, and other non-memory-unsafety bugs, and that a decline in memory unsafety will not meaningfully impact the overall security posture of real-world programs.

      I believe the validity of such responses are context sensitive. There are some niches out there that:

      • Are “computation only”, with no I/O to speak of.
      • Require no dependencies.
      • Require no heap allocation.
      • Have an easy to test control flow.

      As I often repeat here, I wrote a cryptographic library in C. No I/O in sight, it just reads and writes buffers given to it by the caller. Zero dependencies, not even libc (I pretend size_t and uint_*_t defined in stddef.h and stdint.h don’t count). No heap allocation. Mostly constant time code, with no secret dependent branches and no secret dependent indices, making it very easy to test with not only 100% code coverage, but 100% path coverage.

      So in practice, my response to the 3 points above are:

      1. I can make my library memory/undefined safe with sufficient meticulousness, not because I’m such a genius, but because my code is so damn trivial in this particular respect.

      2. No argument there, I love static typing, I know that sound type systems are incomplete, and I’m willing to pay the price in the name of safety. Besides, most sound type systems have explicit escape hatches. (I know, that makes them unsound, but you can easily search for and audit any use of the escape hatch.)

      3. The only serious bug in my library, a critical vulnerability of the highest order, was a logic bug. I would have made the exact same mistake in Rust.

      Now cryptographic libraries are teeny tiny niche, and I’m not sure my argument extends anywhere else. If we relax the “easy to test control flow” bit we should be able to extend this to various encoders & decoders. Parsers maybe, though if the parsed language allows any kind of recursion (like nested parentheses) we may have a problem already. And if we additionally relax the “no heap allocation” bit I don’t think my arguments would hold any water any more.

      In the context of unsafe languages this false confidence manifests in myriad ways, each of which leads to doom:

      1. Confident insistence that C is a “high level assembler,” […]
      2. Confident insistence that a C program “says what it does,” […]
      3. Confident insistence that the compiler will perform all “obvious” optimizations, […]

      Working on my library and testing it quickly disabused me of such notions. I’m very conservative about the assumptions I make of my compiler, and now tend to treat it as I would a sentient adversary. I have to, C would be way too unsafe otherwise.

      1. 8

        It would be good if programmers could write correct low-level code without having to treat their compiler as a sentient adversary.

        1. 2

          Good news! - They can. If you write code that is correct, the compiler will compile it correctly, and if it doesn’t, that is always considered a bug in the compiler. “Wrong code” bugs do happen in compilers, but the compiler engineers generally try to resolve them quickly.

          Problems usually come about when you write incorrect code. Good news there, too: the compiler will not go out of its way to make use of errors in incorrect code in ways that the programmer might not expect; it only does that if it can produce more efficient code (based on the assumption that the code is correct, which unfortunately would not be the case in this instance). However, the compiler is not out to get you! Far from being a sentient adversary, the compiler is in fact a big dumbo who just wants to help.

          One final piece of good news: if you are having trouble following the language rules regarding things such as integer overflow and pointer aliasing, such that you fall foul of the “incorrect code” issue, compilers are happy to help you out! You can use options such as -fwrapv and -fno-strict-aliasing so that they accept even certain incorrect code and still produce code that does what you wanted.

          1. 4

            The compiler does not hate you, nor does it love you, but your code is made out of undefined behavior that it can interpret as something faster.

            (As with AI, sufficiently advanced indifference is indistinguishable from malice.)

            1. 2

              sufficiently advanced indifference is indistinguishable from malice

              … and so one cannot with confidence call it either, surely? But I think we can easily distinguish what compilers do (and more importantly, why) from malicious behaviour.

              1. 2

                Even if we can, what’s the point? If the rules are such as to engender malice-equivalent behavior, the fact that they’re specified as such is no excuse. At most, this is debating where the issue is, not what it is. I have seen LLVM replace entire functions with ud2 on the basis that the control flow hits an “undefined” operation somewhere. (null pointer access in my case, don’t get me going on that one and the so-called “LL” VM). This sort of optimization has never produced any benefit to any human being in the entire history of LLVM. At the extremes, the tails come apart between “taking maximal advantage of undefined behavior for optimization” and “actually improving the generated code”.

                1. 2

                  If the rules are such as to engender malice-equivalent behavior,

                  They’re not.

                  I have seen LLVM replace entire functions with ud2 on the basis that the control flow hits an “undefined” operation somewhere. This sort of optimization has never produced any benefit to any human being in the entire history of LLVM.

                  That is certainly wrong.

                  At this point you’re arguing that introducing an opcode that is guaranteed to crash if executed and thereby alert the programmer that something was wrong with the code, is somehow malicious, even given that it could’ve inserted literally any other code and still been compliant with the language specification. I don’t feel compelled to argue further, but I will say: I disagree and find your statement plainly incorrect.

                2. 2

                  WASM does this too. I think QBE might have equivalent functionality; I know that it can remove dead code. The problem is choosing an input language which allows users to request undefined behavior.

                  1. 3

                    The problem with LLVM (which stands for Clang Compiler BackEnd) is that it is defined to match C/C++ semantics by default, and the more you divert from this the less the framework likes you and the uglier your bytecode looks.

                    addrspace(1), addrspace(1) everywhere…

                    But it’s okay, at least it’s all documented. Just check the documentation generator, clang -S -emit-llvm

                    Anyway! I’m getting away from my point, which is that undefined behavior does not mean “the program must crash here” but “I, the compiler, don’t know what should happen here.” A humble compiler would try to emit the UB in a natural fashion, be careful to avoid making assumptions around it, and trust the programmer to know what they’re doing. A modern optimizing compiler instead takes the UB as carte blanche to reason “1 = 2, ergo genocide.” In the absence of reliable assumptions, it falls back on the “safe” strategy of breaking as much code as it can justify, as completely as possible.

                    A humble compiler would see a null dereference and go “that should sigsegv, right? But I mean, maybe not, what do I know. Let’s load it anyways.” A modern compiler goes “lol ub, say goodbye to the entire dominant cfg. This may crash, so it must crash!” They go full Mephisto.

                    1. 2

                      A humble compiler would […] trust the programmer to know what they’re doing.

                      Ignoring that you are ascribing personality traits to a piece of software for the moment, let me point out that trust the programmer is exactly what the compilers do, and exactly why they have the behaviour that you are railing so hard against:

                      A compiler trusts the programmer to understand the rules and semantics of the language they are programming in;

                      It trusts the programmer to know that certain runtime actions will have undefined behaviour, and that invoking that will render the operation of the program completely unreliable;

                      Therefore, if there is some path of execution in the program that would necessarily have undefined behaviour, it trusts that the programmer will have ensured that this path never actually gets taken - after all, what programmer would write a program that is not guaranteed to operate correctly - and it optimises the code accordingly (by, for example, removing the code path in question).

                      The behaviour of then inserting a UD2 instruction, for example, is the only part that entails not fully trusting the programmer. This part is an example of the compiler behaviour being modified to be more helpful in the presence of incorrect code than it would be if it really did just blindly trust the programmer.

                      A modern compiler goes “lol ub, say goodbye to the entire dominant cfg. This may crash, so it must crash!”

                      This is such an awful mischaracterisation of what the compiler actually does that I hardly know where to begin in correcting it, but hopefully the above goes a good part of the way.

                      1. 2

                        The difference is that in the C machine a null pointer access is undefined, but in the real physical machine that stands next to my desk, a null pointer access is just a call to the SIGSEGV handler. The utter refusal of the optimizer to pay any respect to the fact that the real machine exists is what irks me. I’m not running the ELF binary on the C machine model. The C abstract machine is supposed to be a simplification of the physical processor, which is the point of writing the program. What I mean by humility is that the backend should realize and honor the fact that it does not live in the reality that actually matters. C is defined in the abstract machine, but C is written on and for the real machine. The backend knows this perfectly well when optimizing instruction latencies, but it somehow pretends to forget when encountering UB.

                        1. 3

                          The problem is you’re drawing a connection between the abstract machine and the physical machine that isn’t there. The compiler’s job (forgetting about more esoteric implementations for the moment) is to translate from C source code - which describes a program that runs on the C abstract machine - to assembly or binary code that runs on some machine (eg the one next to your desk) in such a way as that the binary will when executed produce the same observable output as the source program would if it were run on the abstract machine.

                          This doesn’t mean that for every pointer dereference in the C program there necessarily has to be a load of a value from memory through some equivalent pointer in the generated binary.

                          Consider:

                          if (n != nullptr)
                              a = *n;
                          if (n != nullptr)
                              b = *n;
                          

                          It’s a valid optimisation to avoid the double comparison, by effectively transforming this to:

                          if (n != nullptr) {
                              a = *n;
                              b = a;
                          }
                          

                          (p.s. had to edit code example, initial version was wrong, apologies).

                          … wouldn’t you agree? But, it requires that there’s not a one-to-one correlation between pointer dereference in the abstract machine as there is on the physical one. So for this to be valid you have to understand the concept that the one is not the other, that there isn’t any one-to-one mapping between particular language constructs and particular instructions on the target machine, for example. Another example is, if there are a series of additions of the same value, the compiler can turn it into a single multiplication instruction. Would you complain then about the fact that the machine code isn’t matching exactly the source, because it’s not generating a whole series of ADD instructions and instead produces a single IMUL?

                          In other words: It’s not a failure of the optimizer to “pay respect” to the existence of the real machine, it’s that its job is not what you think it is.

                          If you really do want to ensure that a load from a nullpointer executes as a similar load from the zero address on the real hardware, use -fno-delete-null-pointer-checks or the equivalent for your compiler (and stop, please, this rubbish about “malicious” compiler behaviours - because they would in fact do what you wanted if you would only ask them to, and that’s almost the opposite of malicious behaviour).

                          If on the other hand you really want to be able to control the emission of individual instructions at the machine level, then C is not the language for you. What you want in that case is probably assembly language.

                          1. 1

                            Would you complain then about the fact that the machine code isn’t matching exactly the source, because it’s not generating a whole series of ADD instructions and instead produces a single IMUL?

                            I agree with you on all of those. However, I put much stronger emphasis on intent than permission. In all those cases, the intent is to skip steps that we understand to be concretely extraneous. In other words, “because the abstract machine allows it” is necessary but not sufficient - the compiler should also consider whether it produces an advantage for the user on the target machine. Which it does, for many optimizations.

                            and stop, please, this rubbish about “malicious” compiler behaviours - because they would in fact do what you wanted if you would only ask them to, and that’s almost the opposite of malicious behaviour

                            No, I still disagree on this. “You didn’t ask” is a bad excuse. I shouldn’t need to toggle twenty flags to get the compiler to do sensible things, learning about each individually after several hours of debugging.

                            Our folklore is full of entities that do precisely what you tell them to, while actually optimizing for their own inscrutable goals. These stories are universally warnings of terrible danger.

                            In other words: It’s not a failure of the optimizer to “pay respect” to the existence of the real machine, it’s that its job is not what you think it is.

                            And I still disagree on this. The abstract machine defines what the optimizer may assume. The real machine defines what the optimizer should assume. This process requires consideration if an optimization makes sense on the real machine. Again, this is understood for every other case - for instance, the compiler could theoretically replace an IMUL instruction with a constant with several thousand INC instructions, but it does not do this because it would be phenomenally pointless and destructive. So why do we accept that it will do similarly destructive things - things that serve no sensible purpose on the real machine - if it encounters UB?

                            1. 2

                              the compiler should also consider whether it produces an advantage for the user on the target machine

                              Again, the mistake is thinking that the source program is somehow interpreted according to the semantics of the target machine, rather than the abstract machine.

                              or instance, the compiler could theoretically replace an IMUL instruction with a constant with several thousand INC instructions, but it does not do this because it would be phenomenally pointless and destructive. So why do we accept that it will do similarly destructive things - things that serve no sensible purpose on the real machine - if it encounters UB?

                              Because the purpose of optimisation is to make its execution faster, assuming that the code is correct, i.e. assuming that it exhibits no UB; clearly replacing an IMUL with thousands of instructions isn’t going to achieve that. But if UB is encountered, all bets are off. That’s been the case for a long time. Whatever it does in the case of UB is allowed, since the semantics of the abstract machine no longer impose any requirements; so, a simple way to improve effectiveness of optimisation is to eliminate execution paths that definitely exhibit UB from consideration, and that’s what compilers do, because optimisation is a difficult problem and you take your easy wins where you can get them.

                              I shouldn’t need to toggle twenty flags to get the compiler to do sensible things

                              You don’t need any flags to get the compiler to do sensible things.

                              But by “sensible things” you seem to mean “what I specifically want it to do, even though I understand that’s not the default”. You don’t need to toggle twenty flags even for that; your propensity for exaggeration isn’t doing you any favours. And if you can’t accept that “won’t do what I want unless I disable certain optimisations” isn’t the same as “acts maliciously” then there’s nothing more to say.

                              1. 1

                                Because the purpose of optimisation is to make its execution faster

                                There is of course no such thing as “faster” on the abstract machine. This only makes sense in reference to the real machine.

                                the semantics of the abstract machine no longer impose any requirements

                                But this does not create an obligation!

                                But by “sensible things” you seem to mean “what I specifically want it to do, even though I understand that’s not the default”.

                                Correct, because I think the default is bad. Doing bad things for bad reasons is malicious, even if everyone is doing them and they’re legal.

                                I think the disagreement is maybe that you see the abstract machine as “the totality of the law”, such that there is no obligation at all to do anything except where the abstract machine demands it. Whereas I see it as “guiderails”, such that the optimizer should, when stretching beyond the bounds of the abstract machine, try to extrapolate from the boundary. You say “the compiler can do anything on a path that dominates a null load, because null load is ub.” I say “the compiler should treat a null load like a pointer load it can’t optimize, because that’s the defined behavior that a null load is the closest to, and it’s probably what the developer intended, and at any rate it will result in the best crash info.” Should, rather than must.

                                1. 3

                                  I think the disagreement is maybe that you see the abstract machine as “the totality of the law”,

                                  I wouldn’t put it that strongly, but ok.

                                  I think the disagreement is maybe that you see the abstract machine as “the totality of the law”, such that there is no obligation at all to do anything except where the abstract machine demands it.

                                  Using the word “obligation” is entering tricky territory. I think what you are talking about is quality of implementation, i.e. even if there is no hard requirement to do some thing, it still might be a good idea to do some thing anyway.

                                  That I do agree with. We disagree on the boundaries. If you were to say, “I would prefer that compilers did …” then I could say, ok, fair enough (but I would also point out that certain options will enable that exact behaviour, and despite your noises about it earlier, it really isn’t too hard to find them).

                                  There are a bunch of positive/constructive things you can do if you don’t like what C compilers are doing. The easiest are probably to use the right compiler flags or use a different language. If you want to express a preference for a different behaviour from compilers, that is fine too, if you are civil, although I doubt it will get you far (whether you are civil or not).

                                  But, describing the current behaviour of the compilers as “malicious” is objectively wrong, and is an undeserved insult to the people who work on said compilers.

                                  1. 1

                                    What I originally said, just to note, is that at sufficient negligence for code intent its behavior is equivalent to malice. I’m not saying that the gcc/LLVM devs deliberately make my day worse, just that the entirely predictable fact that people’s days are worsened by some of the optimization decisions they make does not seem to weigh heavily on their minds.

                                    See also: Linux 1, Linus 2

                                    1. 2

                                      If you know it’s not actually caused by malice, what’s the point of saying that the effect is the same as if it was caused by malice?

                                      You also said (in reference to compiler behavior);

                                      it falls back on the “safe” strategy of breaking as much code as it can justify, as completely as possible

                                      “breaking as much code as it can justify” is ascribing malicious intent.

                                      There’s no such intent, and you shouldn’t claim or imply that there is.

                                      1. 1

                                        “breaking as much code as it can justify” is ascribing malicious intent.

                                        It is not. It is simply a description of dead code elimination in the presence of ub.

                                        If you know it’s not actually caused by malice, what’s the point of saying that the effect is the same as if it was caused by malice?

                                        Because the point is that it doesn’t matter whether the developers specifically intend bad outcomes or not. It’s not like these are unanticipated outcomes; gcc explicitly requests that you not complain about bad things happening as a consequence of UB optimizations. In general, there are systems that are safe and systems that are unsafe - that may harm you. Unless a system aims to not harm you, unless it is morally aligned, then as it scales up in power it will create harm again and again. Compiler optimizers are an unsafe system - what’s more, they are designed to be an unsafe system in this sense. They will not deliberately harm you, but they will accept harm to you as a consequence so long as their system of law permits it. They optimize performance under the constraints of the abstract machine. They don’t optimize for getting your program to do what you want effectively; they optimize in effect for your program terminating as effectively as possible. Whether this is by main() ending with a return 0 after its task has been done, or by main() immediately aborting because all of its code has been optimized out, is no skin off the backend’s back.

                                        To be clear, I think this is a bad (not “morally wrong”!) way to build compilers.

                                        1. 3

                                          Because the point is that it doesn’t matter whether the developers specifically intend bad outcomes or not.

                                          Of course it matters. As I pointed out, if compiler developers actually were malicious, things would be much more dire than they are now. Claiming that it doesn’t matter is offensive; it’s saying that the developers did such a bad job, the effect is indistinguishable from them actively trying to cause harm - which is so clearly not true that it raises the question of why you’d suggest it, if not to deliberately impugn.

                                          1. 2

                                            Oh no, if the developers intended harm, they could do very much worse. I am completely agreed with that.

                                            I’m not sure how we’re talking so far past each other. I don’t think the developers are either unusually bad (quite the opposite) or unusually evil, I’m saying that the goal that they have decided to optimize for is (in certain corner cases) at odds with the goal that users usually want their compiler to optimize for, and the ensuing harm is is what usually happens when very powerful algorithms are applied to unaligned goals.

                                            Analogously, I also don’t think Youtube, Facebook or Twitter engineers are evil or malicious. - Though there’s a profit incentive there that’s absent for compiler backend writers, which is why I think that in this reference class, they’re actually unusually good.

                                            I just think they’ve chosen a wrong (not morally!) goal to pursue (maximizing optimization pursuant to the restrictions of the C abstract machine), which is subtly offset from the goals of the compiler’s users (getting their program to do its job correctly and fast). The rest is what ordinarily occurs in such a situation.

                                2. 2

                                  There is of course no such thing as “faster” on the abstract machine. This only makes sense in reference to the real machine

                                  Yes, the point is to make the program behave in accordance with the semantics of the abstract machine, as fast as possible, on a real machine.

                                  But this does not create an obligation!

                                  Exactly.

                                  Doing bad things is malice

                                  Nope, malice is about intent or desire.

                                  1. 1

                                    Sorry, rephrased. I also think the reasons are bad.

                                    Yes, the point is to make the program behave in accordance with the semantics of the abstract machine, as fast as possible, on a real machine.

                                    But the reason for that is that the developer wants fast code that matches his intent as communicated by the code. When UB occurs, the developer probably still wants something, the code just no longer communicates it unambiguously. But ambiguity should not be understood as a complete wildcard.

          2. 1

            It’s worth noting that this is true even of a formally verified C toolchain. If you use COMP-CERT, they have chosen to define some classes of UB and IB for their specific dialect of C, but they still come with a caveat that their code generation is guaranteed correct only for input code that doesn’t any UB according to their model of C.

      2. 4

        I just don’t see how the counter-example that some few programs may be written by some subset of C programmers that are small/focused enough to be reasonably considered safe despite the tooling would lend any credibility to the idea of using C in the general case.

        1. 2

          Actually, neither do I.

      3. 3

        Zero dependencies, not even libc (I pretend size_t and uint_*_t defined in stddef.h and stdint.h don’t count)

        No need to pretend! Those are provided by the compiler, not libc - at least, they are provided by GCC. Well, it is a little fuzzy for stdint.h; Glibc [at least] does provide its own version, and GCC’s stdint.h will #include that one if GCC is in “hosted” mode (i.e. -ffreestanding not used). And of course it’s a little fuzzy generally since libc and compiler do tend to walk hand-in-hand. But at least for stddef.h, it simply doesn’t exist in /usr/include.

        (GCC-provided headers can normally be found in “/usr/lib/gcc/(architecture)/(gcc version)/include”).

      4. 3

        Is the library safe regardless of the mistakes made by the caller? Because that’s what “safe” means in safe languages.

        1. 1

          My library is correct, not safe. It has some of the expected C footguns if you give it, say, a pointer to a buffer too small for the specified size, but it also has a couple logic footguns, some of which are unfixable given my constraints.

          Very similar to the CompCert compiler, in this regard: correct code, unsafe interface.

    2. 7

      I’m all for bagging on C but this is a shitty blog post:

      • Section 1: Leading point being that people want to use C/C++ because doing unsafe things is cool is obviously ridiculous when up until very recently there were no viable replacements (and arguably there still aren’t)
      • 1.1: char + char = int is obviously a deficiency of C and not of all unsafe languages
      • 1.2: I put the wrong types on my variables and my program didn’t work, woe is me
      • 2: I wrote incorrect code and my program didn’t work, woe is me. The first example is ridiculous and I can’t think of an example that would behave like that that isn’t also obviously playing with fire. The second example is more unfortunate because C/C++ are unique among languages in that their standard libraries are almost 100% unusable, but this is not a surprise to people who regularly use them
      1. 8

        I wrote incorrect code and my program didn’t work, woe is me

        This is exactly the manifestation of the machsimo attitude. The unwillingness to admit that programmers are not infallible, and they will sooner or later write incorrect programs. It’s not a point of pride to never make a mistake, it’s a naive dangerous overconfidence.

        It has never been an issue whether C can be safe when it’s written perfectly. Every language is. Assembly, Malbolge and typing machine code directly with eyes blindfolded also make perfectly safe programs when the programmer writes perfectly correct code.

        So the real issue is, when the programmer will inevitably write something incorrectly, whether the language will catch it, or cause doom.

        1. 4

          Yeah it is. I should have expanded on that, which I will do now:

          That section presents a theoretical “excellent C programmer”, then has them write something along the lines of int* x = malloc(4);, as if that isn’t exactly the kind of thing you abstract away day 1 because it’s so dangerous to write by hand every time. It is a very real issue that C and its teaching materials do nothing to push you towards designs that make that example a non-issue (you can implicitly cast between pointer types in C out of the box!!), but it’s only a C issue, not an unsafe language issue.

          It doesn’t make sense to act like there’s no useful design space between full footgun and full memory safety just because C exists, much in the same way it doesn’t make sense to say all memory safe languages are bad because Java exists, and no existing memory safe language is an unconditional upgrade over C/C++ so we can’t completely write them off yet.

        2. 3

          Are we reading the same comment? I think your parent commenter would fully agree with the fact that programmers are fallible and writing in C makes it worse. I don’t sense that machismo attitude (which I would agree exists, and permeates things, but this is not that)

          I completely agree with the original comment that this blog post is weak at best. And I disagree with your false dichotomy. We agree: the programmer will inevitably write something incorrect. It will happen. But there’s a much bigger spectrum between the language catching it and causing doom. (The fact this spectrum exists is why the blog post is weak)

          Yes, it’s great when the language/compiler catches things. The more the better! But just because the compiler catches some things, many still go undetected. You can’t catch a logic error, is the point, and you can make a logic error in any language, so why rag on C if all people and languages are fallible?

          To wit, in writing Rust of late, I have had more bugs come out of the dang ? operator. It’s handy, yes, but it’s also an implicit return. There are plenty of a class of bugs where the programmer is trying to ensure some invariants but ends up silently no-oping the function instead of wildly complaining (especially true where Options are concerned). This is a logic bug. And, it’s fine, this isn’t an attack on Rust, just that, yes, programmers are fallible but, unlike the original blog post, writing in an air-quotes-safe language doesn’t help you here. (It can help you in other places, but if the blog post were better, it would expound on those wins, instead of suggesting a panacea)

          1. 1

            There is both the case of “is the language defined well enough such that the compiler can catch it?”, but also “what happens if the compiler does not catch it?”

            The problem in some languages is that if the compiler doesn’t catch it, then the code at runtime fails. In a language like C, the problem is that if the compiler doesn’t catch it, you may have just accepted executable code over the Interwebs that you are now diligently running on behalf of an attacker, or you gave away the keys to the kingdom, or ….

            I’ve personally lived through a number of CVSS ~10 issues on commercial products that I was managing, and also open source libraries that my team developed. (My team included several full time OpenSSL committers when HeartBleed hit, which is the event that I remember most vividly, but it only had a CVSS of 5.0 🤦‍♂️. We used OpenSSL in literally hundreds of different commercial products.) Anything to avoid (and if not avoid, then to reduce the impact from) events like this is worth evaluating. That includes purposefully moving away from low level languages, when high level languages will do just as well.

    3. 6

      Whether their practitioners will admit it or not3, there’s a certain amount of cool machismo affiliated4 with low-level and systems programming, especially when that programming is in unsafe languages.

      I’d argue that this machismo is also the reason programmers are so dead-set on finding/creating/using memory-safe languages without garbage collectors when gc already exists and works great for 90% of use-cases. It makes me think of the “men would rather do X than go to therapy” memes. I recognize that this is probably an unpopular opinion.

      1. 3

        I will happily use GC where the situation makes sense. For application programming, these days I would consider using a language without a GC to be an exercise in extreme masochism, not machismo. Half the time, application code isn’t even CPU-intensive enough to require a compiled language and I’ll happily use something like Lua for the majority of the logic.

        C and C++ are systems programming languages. C++ is a significantly better one, because it at least pretends to have types, but both of them are intended for use cases where you sometimes need to peak below the level of the abstract machine. A memory allocator can’t be written in a GC’d language[1], because it’s providing one of the things that the GC depends on. The same is true for a lot of language-runtime features.

        Beyond that, some larger systems code is very sensitive to tail latency, yet most GC’s are throughput-optimised. Introducing 10ms latency spikes on a system in a datacenter can make tail latency spike into the seconds, which is noticeably to humans. Twitter does some astonishingly horrible things to avoid this in their software.

        Global GC[2] has no place in a systems language. You’re right that GC works great for 90% of use cases. I’d actually put that number higher: GC works great for anything that isn’t systems programming, and that is well over 99% of programming. Safe systems languages are still rare / nonexistent (Rust is safer, but since it needs unsafe for any data structure that isn’t a tree and has no way of guaranteeing safety properties in FFI, it isn’t safe), safe applications programming languages are common and widely used. People using systems programming languages for things that are not systems-programming problems are both inviting and causing suffering.

        [1] Well, okay, MMTk exists, but it’s not noticeably easier to write a memory allocator in it than it is in C/C++.

        [2] Local GC can be incredibly useful, but that depends on having an abstraction for ‘this related set of objects’, which most systems languages lack currently.

        1. 2

          For application programming, these days I would consider using a language without a GC to be an exercise in extreme masochism, not machismo.

          I’m not so sure, particularly for local (i.e. desktop and mobile) applications. This post suggests that Apple’s rejection of tracing GC in favor of reference counting has been a competitive advantage for a while. Now, if you want to say reference counting (particularly without cycle collection, as on Apple platforms) is a type of GC, fine. But then, Lua doesn’t use reference counting.

          To me, using a type of memory management that frees up memory as soon as we can, rather than using more of it and then cleaning up sometime later when we think we’ve used too much, is a way of putting our users first, not a form of machismo or masochism. Then again, given economic realities, maybe we have to be masochistic to truly put our users first. Anyway, I’d settle for top-to-bottom reference counting, as in Apple-native apps, Windows C++ apps, or even classic VB, as an alternative to tracing GC; I think the adoption of tracing GC in most modern managed runtimes is a mistake for apps running on our users’ machines.

          1. 1

            I’d probably include automatic reference counting as GC, though sometimes the requirement to explicitly break cycles can be painful. That post suggests that the memory consumption of ARC is lower than GC. That’s not what I heard from folks on the Apple compiler team, the key was determinism. GC gives less predictable worst-case memory consumption and this was critical for the iPhone because it didn’t swap and so running out of memory would kill a process. This was a lot worse on the early iOS devices, where there was only enough memory for a single graphical application and so a GC peak would cause the foreground app to die, whereas on more modern devices it would instead cause a background one to exit earlier than normal.

            It also didn’t help that Apple massively screwed up adding GC to Objective-C. The type system didn’t differentiate between scanned memory and I scanned memory and so it was trivial to accidentally introduce dangling pointers. You could had three allocation mechanisms (two via the same function) that all returned void*. One returned a pointer that was not GC’d. One returned a pointer that was GC’d, but wasn’t scanned by the GC. The third returned a pointer that was GC’d, but was scanned. The last case stored u typed data and so had to be scanned conservatively: integers that happened to contain the address of a valid allocation would introduce leaks. Remember that the original iPhone was 32-bit: the probability of a random integer pointing to a valid allocation and keeping an object live was high in a 32-bit world. Oh, and the stack was also conservatively scanned, with the same problems. This meant hat you could easily write a program that would use 100 MiB of RAM on 99 runs of the same test and 200 MiBs on the 100th.

            For most desktop or mobile apps, I simply don’t care about that anymore. The system has enough ability to handle the occasional memory spike (via swapping, paging out cached files, or killing a background app) that a user is unlikely to notice. Computers are fast enough that GC pause times are well below anything a human can notice.

            On the other hand, for datacenter applications, a GC pause can really hurt. If your average response time is 1ms but a GC pause turns it into 10ms, that doesn’t matter much locally. If it’s one of 100 queries that you need to respond to a remote request then now the probability of one of those nodes hitting a GC pause is high enough that it impacts average latency and therefore throughput. Twitter did some awful things to try to mitigate this problem.

            1. 1

              I think it’s still fair to say that, over most of the life of a long-running application, ARC leads to lower total memory usage than GC. I think about it this way: a GC-based application blithely allocates memory until it hits some threshold, then it cleans up (perhaps only partially), then it goes back to allocating memory like it’s unlimited for a while, and the cycle continues. An ARC-based application frugally frees memory for each object as soon as that object can no longer be reached. (Of course, reference cycles lead to leaks, but hopefully we can avoid those.) To me, the latter is much more considerate of our users. We developers and power-users, who typically buy capacious machines and manage them well, may have enough extra memory to handle the excesses of GC. But we really ought to assume that our users are running our software on machines that are already a bit overtaxed. Particularly in the desktop world, where the OS doesn’t just kill background apps (and, for some kinds of background apps, we wouldn’t want it to).

              And yes, I’m unhappy that my current desktop application project, which is an application that runs continuously in the background on users’ machines, is Electron-based. I want to do something about that; the business case just isn’t there yet. Maybe at some point I’ll be able to do the masochistic thing and put my own personal time into it anyway.

              1. 1

                It’s not that clear cut. Reference counting requires more per-object state than tracing, though you may be able to pack that state in another word in the common case (which adds some overhead to atomic paths), but you generally can’t for weak references. These end up requiring an extra allocation to track the state, so a cyclic object graph will often need 3-4 words more memory per object with RC than tracing.

                In Apple’s case, there’s also the complexity of the autorelease pool. This is used to elide reference-count manipulation for an object that has a lot of short-lived assignments (e.g. things passed up the stack and manipulated on the way). Once an object is autoreleased, it won’t be freed until (at the earliest) the end of the lexical scope of the autorelease pool. Around 15 years ago, I encountered some Objective-C code that allocated a load of short-lived temporaries in a loop and ended up with 500 MiB of garbage in an autorelease pool (my laptop at the time had 1 GiB of RAM). This was easy to fix by adding an autorelease pool into a hot loop, but that’s basically the same as adding an explicit GC call in a hot loop.

                Electron has a lot of overhead from things beyond the GC (not least the fact that it runs at least two completely separate v8 instances). On my Mac, the Signal app (Electron, but well written) does not use noticeably more RAM than other comparable apps.

        2. 1

          I’m actually 100% in agreement with you — except that it’s de mode to develop applications in a certain systems programming language with “zero cost abstractions” and to deride garbage collection as heavy and slow.

    4. 3

      it’s easy to miss that c1 + c2 cannot observably overflow

      I’m not getting this statement. First, how do you observe overflow in any architecture without either putting the result in a wider type or checking the ALU flags? Second, how is the result of adding two chars different whether or not they’re promoted to int as an intermediate? Either way, you add the 8 lowest pairs of bits and throw out any higher bits in the result. (Unless we’re talking about signed math without two’s complement, which is super niche.)

      1. 1

        In my understanding the problems crop up when standard doesn’t say “this can’t overflow”, it says “overflow is undefined” which means the compiler is free to let it quietly overflow, since we’re in undefined territory, but also that any code you write that attempts to control this is invalid by definition and the compiler can do whatever it likes to that code, and if introducing any particular transform might make some completely unrelated “bug-free” benchmark slightly faster they, will do so.

    5. 3

      In a reasonable world I’d be mildly annoyed at a post like this just because everything it’s saying is so searingly obvious, but unfortunately we don’t live in that world.

      C is a really good choice when you’re using it for what it was originally designed for; unfortunately it was designed to program a PDP-11, which isn’t a thing that there’s a lot of call for nowadays.

    6. 1

      In some cases these confidently incorrect beliefs can be automatically caught. But the culture of false confidence cannot itself be caught: C is a motorcycle, and turning it into a tricycle removes the “cool” factor that attracts so many people to it.

      For the record, if I could develop in C with the safety of a tricycle, but have the resulting code be acceptable to the motorcycle crowd (at least enough for them to use my library, whether or not they contribute to it), I’d accept that. Probably not for my current major library project though, since I’m way too far down the road of writing that one in Rust.