Parsing protobuf at 2+GB/s: how I learned to love tail calls in C (2021)
blog.reverberate.org
blog.reverberate.org
What I like about it, over standardizing [[musttail]], is that lifetimes of local objects are guaranteed to end. This makes it possible to implement without extensive escape analysis.
[0] https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3266.htm#5...
And yet, no fix in sight for proper strings, arrays, or at very least bounds checked slices, like Dennis Ritchie originally proposed to ANSI/ISO.
Can you elaborate on how "return goto" is easier to implement? [[musttail]] also ends the lifetime of local objects AFAICS.
One thing I'll mention from a quick scan:
> [The] function called in tail position must have identical type to the callee. This ensures both that the return value does not require any conversion, and also that argument passing space is available and calling convention (if relevant) is maintained.
One complaint I've seen repeatedly about [[musttail]] (which I implemented in Clang) is that this constraint is unnecessarily strict, since some architectures will allow tail calls even for functions that do not perfectly match: https://github.com/llvm/llvm-project/issues/54964
"But then the code would be nonportable." True, but tail call optimization is inherently nonportable, since some targets fundamentally do not support tail call optimization (eg. WASM without the tail call extension).
Can't any tail call be rewritten as a loop? Couldn't a WASM compiler without the tail call extension implement it this way?
No. In general tail calls cannot be rewritten into loops.
Consider a state machine where each state is a function, and you transition into a different state by tail-calling another function.
State machines can have arbitrarily complicated graphs, that would be hard to put into a simple loop.
(However, you can do something like a 'trampoline' to remove the mutual recursion.)
function logAndCall(statement, func) { console.log(statement); return func(); }
Tail call optimization is actually possible here since we call func in "tail position". It might be unlikely to blow up the stack, but it can definitely happen if you do a lot of continuation passing.
Perhaps more commonly for C++/Rust, tail call optimization would be enormously valuable to have behind the scenes for destructors. It's actually very difficult to implement a linked list in safe Rust that doesn't explode the stack for large lists since no TCO is done for destroying subobjects. You can still avoid stack overflow, but you have to do things like manually enumerating the list.
And that's not a special case for passed-in function pointers; that's what a trampoline always does. Trampolines expect static-invoked tail-calls to be codegen'ed into function-pointer references too.
It's completely different than the story with tail recursion which can be essentially reduced to syntactic sugar over a normal loop (just change the parameters to be mutable variables).
Doing it with a trampoline is probably slower than if C really had tail calls. On the other hand, adding "real" tail calls to C would probably require changing the ABI (e.g. to "tailcc" or "fastcc -tailcallopt"), and I think there's some reason to think this would probably impose a penalty everywhere (https://llvm.org/docs/CodeGenerator.html#tail-call-optimizat...).
But [[musttail]] does exactly this while respecting existing calling conventions: https://clang.llvm.org/docs/AttributeReference.html#musttail
It would be nice if the more relaxed rules of the LLVM musttail marker with tailcc could be exposed in clang (and gcc). I think that's basically what "return goto" would do.
That's good to know. I had this github issue [0] in the back of my mind, as well as witnessing occasions of clang turning [[musttail]] into inner loops, and concluded clang's implementation must be more sophisticated than simply replacing calls with jumps. Just a little paranoia from trying to be serious with compiler dev[1]: fulfilling a laid-out spec feels more sound versus imitating something out there.
>this constraint is unnecessarily strict
I would agree, at least for x86 psABI, it can be pretty elaborative as long as the return value is the same register and argument stack don't exceed what's provided. Debug/profiling side might hate it, though.
[0] https://github.com/llvm/llvm-project/issues/72555 [1] https://github.com/fuhsnn/slimcc/
That said, my rough understanding is that a tail call ends the lifetime of all objects in the old stack frame. It follows that it is UB to access any objects from the previous stack frame after a tail call, and that would include Gerben's first example in https://github.com/llvm/llvm-project/issues/72555
Your slimcc project looks really interesting, thanks for the pointer.
[0]: https://github.com/rust-lang/rfcs/pull/1888 [1]: https://github.com/rust-lang/rfcs/pull/3407
Also, the main reason why interpreters get faster using either the computed goto style or the tail style versus a classic switch loop is that it reduces pressure on the branch predictor since there’s statically one indirect branch per opcode rather than statically just one indirect branch.
Register assignment is much less fragile when each function is small and takes the most important variables by argument.
In my experience writing computed goto interpreters, this isn't a problem unless you have more state than what can be stored in registers. But then you'd also have that same problem with tail calls.
The goal is for fast paths to avoid spilling core interpreter state. But the compiler empirically has a difficult time doing this when the CFG is too connected. If you give the compiler an op at a time, each in its own function, it generally does much better.
In interpreters, my experience is that fallback paths are well behaved if you just make them noinline and then ensure that the amount of interpreter state is small enough to fit in callee save regs.
Mike's argument about control flow diamonds being bad for optimization is especially questionable. It's only bad if one branch of the diamond uses a lot more registers than the other, which as I said, can be fixed by using noinline.
Also, it probably depends a lot on what you’re interpreting. I’ve written, and been tasked with maintaining, computed goto interpreters for quite a few systems and the top problem was always the branches and never the register pressure. My guess is it’s because all of those systems had good noinline discipline, but it could also just be how things fell out for other reasons.
Could you elaborate on this with a couple more paragraphs? What do you mean by one indirect branch per opcode rather than just one indirect branch? How is this achieved?
Say you write an interpreter like this:
for (;;) {
switch (curInst->opcode) {
case Inst::Foo:
setOperand(curInst->dest, foo(getOperand(curInst->a), getOperand(curInst->b));
curInst += FooSize;
break;
case Inst::Bar:
setOperand(curInst->dest, foo(getOperand(curInst->a), getOperand(curInst->b));
curInst += BarSize;
break;
... more stuff like this
}
}
Then the generated code will have an indirect branch for the switch. This is costly for two reasons:- Gotta load curInst->opcode, then lookup opcode in a compiler-generated jump table to get the code address of the case statement.
- Gotta indirect jump.
It turns out that the top cost (or even all of the cost) is the indirect jump. Maybe, the indirection is also some cost, at least on some CPUs. But remember, all jumps on modern CPUs are fast if predicted - regardless of the work seemingly required to do the jump. And they are slow if they are not predicted - and that slowness is about much more than the work required to do the jump (the cost is the CPU needing to roll back its speculations).
Reason: the indirect jump prediction the CPU is doing is keyed by the address of the indirect jump instruction. There is one indirect jump in the code above. And for any real program you'll execute, that indirect jump will end up hitting all (or at least most) of the opcodes. Therefore, the CPU has no easy path to predicting the indirect jump. Maybe it'll sometimes get lucky with more sophisticated predictors (like ones that track history and use that to salt the key used to lookup the predicted destinations, or ones that do more fancy stuff, like maybe some neural network to predict).
How do you make the indirect jump faster? Have one indirect jump per instruction! Both the computed goto approach and the tail call approach get you there.
Consider the computed goto version of the interpreter.
Inst_foo_label:
setOperand(curInst->dest, foo(getOperand(curInst->a), getOperand(curInst->b));
curInst += FooSize;
goto *curInst->handler;
Inst_bar_label:
setOperand(curInst->dest, foo(getOperand(curInst->a), getOperand(curInst->b));
curInst += BarSize;
goto *curInst->handler;
... more stuff like this
In this formulation of the interpreter, there is one indirect jump per instruction, rather than just one for the interpreter. Therefore, we're asking the CPU's predictor to do something simpler: to predict, for each instruction, what the next instruction will be. And then on top of that, the predictor still gets to use its neural network, or history buffer, or whatever. In terms of mathematical probability theory, it's like we're giving the CPU a first-order Markov chain, and that's sure to improve prediction accuracy. Empirically, it improves it a lot and it's a big speed-up. Here's yet another way to think of it. If I asked you to predict, "what's the next instruction", without any knowledge of what the prior one was, then you'd have a hard time - you'd only be able to reason about which instructions are most likely generally. But this computed goto interpreter is instead asking: "if I tell you the instruction I just executed, what's the next instruction", which gives you more leverage. Maybe adds are often followed by moves, for example.The tail call style also achieves this, because each instruction's handler will have an indirect tail call (literally an indirect jump) to the next instruction, which again, gives the CPU that Markov chain goodness. So let's call both the computed goto style and the tail call style the "Markov style". If you could rewrite the switch statement so that there was one switch statement per instruction (and you could convince the compiler not to combine them into one, lmao) then that would also be a Markov-style interpreter.
As for the cost of indirectly loading from the compiler's switch table, or other issues like pushing and popping registers: in my experimentation, these costs are like drops in the ocean compared to the cost of indirect jumps. Even with the Markov style interpreter, the CPU spends most of its time mispredicting and rolling back. So the details of how the work happens for individual instructions are usually less important than what you do about the prediction of that indirect jump.
There's one thing I'm still missing. How exactly do we force the CPU to use the last opcode in its branch prediction model? In your first switch example, the CPU "knows" the path it has followed to get to each iteration, so in theory it could use the information of the last node it visited (or the last two nodes, etc.) to aid branch prediction right?
Related to that: in your second example, what exactly happens in `goto *curInst->handler;`? Doesn't this need to revert back to something like a switch statement which has the same problem? (Unless you are doing polymorphism / dynamic dispatch in that example? Which I assume has some performance penalty that you're saying is dwarfed by the extra branch prediction effectiveness). Analogous to the line in the OP's article that says `MUSTTAIL return dispatch(UPB_PARSE_ARGS);` - doesn't the generic dispatch function need another switch statement? Probably missing a couple obvious things here.
Lastly - if you have any books/article recommendations that helped you learn some of these intricacies (esp. regarding intuition about which performance quirks matter vs. don't matter) that would be great as well. Thanks!
In the tail call case: because each opcode is implemented by a function that has an indirect tail call at the end.
In the computed goto case: each `goto curInst->handler` is its own indirect branch.
> Related to that: in your second example, what exactly happens in `goto curInst->handler;`?
`curInst->handler` is a pointer that points to some label. The goto is an indirect branch to exactly that pointer value, i.e. that label.
It's a super crazy and dangerous feature! It obviates the need for a switch; it is the switch.
On older CPUs, you’re less likely to hit a pipeline stall. This technique was called “super-pipelining” for that reason. But a few years ago when this was discussed, someone pointed out that’s usually not necessary anymore. That branch predictors can see through double jumps now.
But as I alluded to in another reply, CPU caches are finite, and I have doubts whether in a fully realized interpreter, particularly one living side by side with a JIT, if that microbenchmark is lying to you about how fast the inner loop is under production conditions.
But I wonder if that stays true as the size of the interpreter increases.
It might not matter for very small interpreters, but it does matter for anything substantial.
Definitely worth remeasuring though.
That’s why JSC and V8 have interpreters as their bottom tiers.
And you’ll also want an interpreter if you don’t have permissions to JIT, which is common these days.
Sure modern security concerns make use of W^X, and implementations have adapted accordingly.
Android has an interpreter hand written Assembly for fast start since Android 7, with a jump into JIT compilation as fast as possible, because it has been proven to be a better solution in the field.
The point being that a fast start doesn't really require an interpreter to win out micro-benchmarks game, rather be good enough until the tiered JITs step in.
Fast start absolutely does require an interpreter and it is true that the jump to JIT is very quick - but that jump never happens for a large number of run-once functions (like initialization code or data definition code). Having that interpreter also saves you memory, since initialization code tends to be quite large.
See the luajit remake blog for an exhaustive analysis and alternative using an intermediate compiler https://sillycross.github.io/2022/11/22/2022-11-22/
I even saw an enhancement recently that will make preserve_none allocate arguments in the registers that are usually callee-saved: https://github.com/llvm/llvm-project/pull/88333
This will make [[musttail]] + preserve_none a winning combination when used together, particularly when making non-tail calls to fallback functions that use a regular calling convention, because all the arguments to [[musttail]] functions can stay pinned in the callee-save registers.
I'm delighted, because this matches what I originally proposed back in 2021, except I called it "reverse_ccc" instead of "preserve_none": https://github.com/haberman/llvm-project/commit/e8d9c75bb35c...
preserve_all also exists, and has existed for a while. You could use it on fallback functions to help the tail calling functions avoid spilling. But this always seemed like an unsatisfactory solution to me, because it's too intrusive (and requires too much diligence) to tag a bunch of regular functions as preserve_all. It's much more practical to tag all the core interpreter functions as preserve_none.
#define PROTOBUF_CC
Is there any chance of this getting out into the wild or is it too dangerous for us mortals?Also, until quite recently preserve_none was incompatible with the sanitizers, but I believe this may have been fixed by: https://github.com/llvm/llvm-project/pull/81048
Like everything else in CS, when the cost balance shifts for different kinds of operations, the best algorithm can shift back to something we haven’t used in fifteen, twenty years. It contributes a lot to the faddishness of programming. Just because we are bringing something back doesn’t mean there’s no reason. But we forget the reasons it wasn’t a panacea last time so that’s still a problem.
If your main JIT gets faster or slower, then the cost-benefit for running it changes, so the threshold to trigger it gets adjusted, and now the amount of code that runs in the other tiers shifts, which might make the amortized cost of that tier worse. It’s like balancing a double pendulum.
If you can make a JIT tier fast and dirty enough, you can skip the interpreter entirely. And, from my armchair position, it seems that the cognitive load of bookkeeping tasks between the interpreter and say two JITs is high enough that a few languages have mothballed the interpreter and used a JIT optimized for compile time not output speed.
And I don’t recall what language, but I’m pretty sure at least one team that did this ended up dropping an intermediate compiler as well, because of that balancing act I mentioned above. It was better to double down on two than to try to handle three.
AFAIK, attribute musttail is in the process of being added to GCC (the patch is under review) with semantics compatible with clang.
In the meantime some inline assembly macro trickery might help.
edit: code duplication can also be obviated by templating your op function with a fast/slow parameter, with the fast variant tail-calling the slow variant when it cannot perform the fast path, while guarding the slow code via the compile time parameter. The downside is yet more code obfuscation of course.
What's realistic here is that you get a diagnostic if the compiler cannot generate a tail call. For many users, that will likely be good enough. Guaranteeing a tail call as in Scheme is unlikely to happen.
foo()
{
auto a = SomeClassWithADestructor();
return bar(); // This is not a tail-call because the destruction of a happens *after* the call to bar();
}Yes; there is an "as-if" rule in the C++ standard that says that the implementation must emulate only the "observable behavior" of the abstract machine defined by the standard. The observable behavior is: access through volatile lvalues, data written to files, and I/O to interactive devices.
Does the C++ spec necessarily say that destructors are called at the end of the block, compared to, let's say, as soon as the variable is no longer used?
Yes, as long as the destructor has any side effect:
> If a variable with automatic storage duration has initialization or a destructor with side effects, an implementation shall not destroy it before the end of its block nor eliminate it as an optimization, even if it appears to be unused, except that a class object or its copy/move may be eliminated as specified in 11.10 [1].
Other wise things like std::lock_guard[2] would not work; that's an example of a type that is never explicitly used after definition, but used purely for the side-effects of its constructor/destructor.
Also if the error handling function is unlikely, you wouldn't care too much about how fast it is to call it?
To me it seems like a structure of the form
<read data>
if (unlikely(malformed))
return error();
<prep more stuff>
switch (data_type) {
case x:
return handle_x();
case y:
return handle_y();
}
generates a nice jump table quite reliably.Second, given the existence of branch target buffers (and the ruinous cost of mispredicted branches), you really want the instruction dispatch to be a single indirect branch at the end of each instruction implementation, and for that standard tools are somewhere between unhelpful (you can write a macro containing switch (*insn++) { case INSN_FOO: goto impl_foo; /* ... */ } but it’s anybody’s guess whether you’re getting a single jump table for all copies of that) and actively obstructive (“tail merging” in older versions of Clang would actively destroy any attempts at copying dispatch code). Granted, sometimes things work out (new Clang versions can sometimes go “looks like you’re writing an interpreter” and turn a vanilla switch in a loop into duplicated dispatch code). Then again, sometimes they don’t, and you can’t actually know.
for instruction in instructions {
switch (instruction) {
case OPCODE_X: //....
case OPCODE_Y: //....
case OPCODE_Z: //....
}
}
This is how VMs have been written since the dawn of time (or using computed gotos, another non-standard addition to C). It has problems though, like the fact that the `switch` branch is extremely unpredictable, and that you get a massive function which is hard to optimize. This [[musttail]] trick is a huge improvement. But yeah, if you got to support compilers that don't have [[musttail]], you in essence have to have two implementations, the [[musttail]] one and the loop/switch one.In the context of this article, it's all about the performance. Switch-case generates suboptimal code for the commonly used fast paths of the protobuf parser, because the mere existence of the slow paths is enough to interfere with the performance of the code around them.
Also TBF, hardly any real-world C code is strictly standard compliant. Many C compilers just agree on a common syntax that includes both the C standard and some popular non-standard extensions.
PS: C++ compilers actually ignore unknown attributes since the `[[attribute]]` syntax has been standardized in C++11. In GCC and Clang you'll get a warning in the standard warning set, but not in MSVC.
PPS: C23 also standardized the `[[attribute]]` syntax and also added a way to check for supported attributes:
Ah, the joys of writing "portable" C code with #ifdef spaghetti, across commercial UNIXes and their own C compilers, 20 years ago.
It only got better because for many people just like they assume Web == Chrome, C gets C == GCC, blessifully ignoring everything else.
Nowadays clang is also considered, mostly because a couple of companies wanted to replace GCC, and naturally clang needs to be able to match whatever GCC offers.
Not at all guaranteed. Stack overflow is undefined behaviour, which means compilers can optimise your program on the assumption that it doesn’t happen.
(And only necessary in languages that have trouble implementing function calls properly.)
it's just sprawl with popular support.
Additionlly "Language extensions are a feature, not a bug" seems only to be valid in the context of C and C++, IF the compilers being discussed are GCC or clang, because God forbid a comercial C or C++ compiler to have language extensions.
Speaking in general about the way these subjects are discussed online, not you in particular.
I've often pondered the utility of similar flags for other optimizations. This is perhaps the largest one, but there are other situations in certain code where I want to know that my optimization has failed.
A more complicated example is, I've sometimes wanted an assertion that a given function is inlined. We know from hard, repeated experience over decades that letting users annotate functions as inline directly doesn't end up working very well, but I've often wondered about creating an assertion that would fail the compile if it isn't. (Ideally with at least a hint as to why the compiler failed it out, but that's easier said than done.) Obviously you don't want to go slapping this in some major open source library that's going to be compiled by half-a-dozen compilers on dozens of operating systems, but for my own code in my own situation it can be an optimization that is the difference between success and failure and it'd be nice to flag a failure.
(Bear in mind I am not proposing to blindly take any particular action if it triggers. The point is to bring it up to human attention and not have it buried deep in invisible, yet potentially consequential, compiler decisions. The human deciding "I guess this optimization isn't happening" and removing the annotation would be one valid decision, for instance.)
Try `__attribute__((error("not inlined")))` or `warning` on the callee.
OK, maybe “force” is not the right word—nobody says the compiler has to have a single stack frame structure for all possible execution paths of a function. Nobody even says it has to use the standard ABI for a no-linkage (static or anonymous-namespace) function (that doesn’t have its address taken). But the reality is, all compilers I’ve seen do, including Clang, so we want a way to tell them to not worry about the ABI and avoid wasting time on preserving registers across the call.
Re your nice jump table, sure it does. But if you try running the result under, say, perf report, and your test bytecode doesn’t describe a short loop, you’ll see one of two things: either you had a branch mispredict on each dispatch; or the compiler went “looks like you’re trying to write an interpreter” and moved the indirect jump to the end of each case (I’ve seen Clang do this). And either way the register allocation in the resulting code probably sucks.
that's what -fvisibility=internal already does, no?
(Granted, it’s not like this is completely impossible—I seem to remember GHC and MLton can invent custom calling conventions for Haskell and SML respectively. But the popular C or C++ compilers can’t.)
Jumping via function pointers would probably not be as predictable, and you'd unlikely to see the same benefit.
Of course, one must measure, and i haven't.
edit: trampolining would also collapse all indirect jumps to a single source address which is not ideal for branch prediction.
Incidentally, this is why implementors of (especially) high level languages are annoyed that TCO was removed from the JavaScript specification. There are even solution for having TCO and still have stack inspection.
https://github.com/schemedoc/bibliography/blob/master/page8....
* Since this library is only offered as a binding to some managed languages there are some extra caveats that, in term of performance overshadow everything else. I cannot speak to Ruby or PHP, but with Python I saw a dramatic speed increase when not using enumerators. I.e. if you translate Protobuf's enumerators into Python's enumerators any possible gains you may have in your C code will be trampled by creation times of different Python objects. The difference is many orders of magnitude. Similarly, you could go even further and implement all the supporting data-structures in C, and only expose minimal interface to them in Python. How fair would this sort of comparison be against code that uses Python's built-in structures is the question I cannot answer.
* Google's Protobuf parser for Python would be still "faster" than 2+GB/s because... it doesn't parse anything beside the top-level message. The internal structure of the message is parsed on-demand. This will be probably slower than 2+GB/s if your code instantly reads everything that was parsed, but the obvious question would be: how can you possibly compare these two approaches in practical terms? And, again, there isn't a clear answer, because the practical results will depend on the nature of your application.
* In general case, Protobuf parsing cannot be streamed (because of the handling of duplicates). This means, that, in practice, code that parses Protobuf content will be bottlenecked on I/O (it needs to wait for the end of the message before any parsing starts). Independently, depending on the typical Probobuf message in your application, it might be possible to parallelize parsing, which, again, will most likely outrun any single-threaded parser, but, just as examples above, cannot be said to be a winning strategy in general.
* It's usually a lot more efficient to combine parsing with creation of domain objects, which is a step an application will almost always have to take anyways. How this functionality is accessible from the parser will in many cases determine which parser will win the race.
----
Bottom line: Protobuf (or maybe parser in general) is just a bad candidate to try to measure / compare speeds. It's too low-level and poorly designed to be a good measuring stick for performance benchmarks.
I don't see how last field wins stops you from streaming parse. Please elaborate.
{ x: 1, y: 2, x: 3 }
The streaming parser then reads field "x" with the value of "1", dispatches that value to the outside code, then reads "y", then reads "x" again, but the outside code had a side-effect associated with that field, which it already performed (eg. the outside code was printing the value of the field). Now, you have a program with an error. The right output should've been: y: 2
x: 3
but you got: x: 1
y: 2
x: 3
Might not be so bad, depending on circumstances, but if you are betting on a soccer game outcome...However, the interpretation of the payload does require removing repetition, and in a bad way: the compliant implementation must remove all but the last matching key-value pair.
It's just plain stupid. But this stupidity isn't unique to Protobuf. There are plenty of stupid formats like this one. For example VHD (Microsoft's VM image format) puts some information necessary to interpret the image in what they call "footer" (i.e. at the end of the file). MOV (Mac QuickTime videos) and at least early versions of MP4 created on Macs used to put some metadata in the end of the file, making it impossible to stream them.
Unfortunately, it happens a lot that people design binary formats for the first time, and then the format succeeds for unrelated reasons. We have tons of bad but popular formats. PNG is up there at the top of the list together with HTML and PDF. Oh, and don't mention PSD -- that one would probably take the cake when it comes to the product of how bad it is and how popular it is...
You could, but you are not allowed to. Protobuf parsing requires that the last duplicate key wins.
[1] https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html [2] https://clang.llvm.org/docs/ClangCommandLineReference.html#t...
My understanding is that musttail is useful because it prevents stack frame construction and register spilling; the calling function basically says "you can re-use the existing stack and not worry about my local variables."
But doesn't the stack frame construction overhead / register spilling only occur when the fallback path is actually invoked; therefore if it is unlikely and you care less about performance in this slow path it doesn't matter if you wrap the fallback path in musttail?
(Is this purely a branch prediction effect, where the possibility of extra work needing to be done slows down the fast path even when the work does not need to be done?)
In idiomatic rust, this means very few functions can use become.
[0] https://www.mail-archive.com/gcc@gcc.gnu.org/msg95265.html
I think the work on C23 (which would have still been C2x when this article was written), means people are starting to want to see innovation in the language again. I don't think that would have happened without Go and Rust showing some great thinking, but it's interesting that it's happening at all.
Tail calls are an interesting idea, and now I want to know more about both this extension, and also how to write my code in such a way as to help a compiler optimize for tail calls even when this extension isn't present. This somehow feels more fun to me than writing more Python or Ruby...
I don't write C, and maybe it's because I somehow seek out these types of article and projects, but I'd agree, I'm seeing the same thing. It might be a backlash against programming languages like Rust or even JavaScript. Rust being really complicated, but clearly safer, and JavaScript... well it's just really hard to set up a development environment and the tooling is almost more predominant than the language itself.
While I don't write C myself, only for fun, I can see many just reverting to it, because it's "right there", it's fast, the language is fairly simple, even if the standard library is anything but simple, but you can take the stuff you need an ignore the rest.
[1]: https://justine.lol/
The world has gotten a lot more dangerous than people realize. People generally quite correctly assume that hackers aren't going to spend person-months attacking their system personally but don't realize that the attacker tools are quite sophisticated now and they don't have to. Shoving code into a small little buffer overflow to lift to a larger buffer overflow to load exploit code over the network that will run a pre-packaged root priv escalation to install a rootkit to add them to their botnet is no longer the work of a team of hackers for months. It's all off-the-shelf tech now and it's more like writing a 20 line function now; you don't need to attract very much attention now to attract that level of sophistication.
We are leaving C behind collectively for very good reasons. If you are playing with C and you do not intimately understand those reasons, you're going to relearn them the hard way.
Maybe for embedded, I haven't done that, but for general purpose, I can't imagine it being worth the risks.
I looked at Rust and also some rewrites, and I am not convinced. Also especially Cargo is a complete disaster.
You may think you can handle C. I disagree. The evidence is on my side. We need safer languages.
Heck, that even overstates it. It's not like we need super-safe languages per se. Maybe the 2070s will disagree with me. But we need languages that aren't grotesquely unsafe. It's not just that C isn't as safe as a language could be; it is that it is recklessly unsafe.
Interrupting that descent is foolishness, and pinning your career to a resurrection in C even more foolish. These technologies always have this meta-meta-contrarian phase to them just before they expire. I got into the computer field just as the meta-meta-contrarian "everything must be written in assembler" phase was at its peak. It was a false resurrection and I pity anyone who overinvested in learning how to write large applications in 100% assembler as a result of reading someone's over-enthusiastic screed about the virtues of writing pure assembler in 1998.
So I write this in the hopes of helping some other young HN reader not be fooled. C may be a thing you learn at some point, some day, just as assembler is still something you may learn. But don't get overexcited about the last meta-meta-contrarian false resurrection before death. Which is still years away, but at this point I think it's pretty much set on an irrevocable course.
I also agree that C still needs to become safer, and especially the defaults. And it will.
Because we have the benefit of hindsight. We tried putting the responsibility in programmers' hands. It didn't work. We're now learning from that experience and building better tools that can do the same job, more safely. It's foolish not to use them.
> Whatever language you believe in probably depends on C. You live in a C world. The value prop of the programming languages other than C/C++ for systems programming is that they build cohesive social communities and C is in the background serving them all. The whole "we're going to save you from the bogeyman" is just kool aid.
No one is expecting the new languages to make computing suddenly error-free from top to bottom. It's about reducing the size of the surface where errors can be introduced. You're attacking a strawman here.
> I think the work on C23 means people are starting to want to see innovation in the language again.
I have to admit this makes me nervous. Part of what's nice about C is its simplicity and its stability. It's relatively easy to write a compiler, and the lack of having many different ways to do the same thing means all code written in C tends to "feel" roughly the same (outside some domain-specific areas), which means it's easy to dive into a new codebase and get up to speed. It's basically all structs & functions and abstractions built upon them. That's it.
While I definitely am not opposed to making improvements to the C spec, seeing the inline anonymous functions in a proposal linked elsewhere in this thread[2] really turns me off. It starts to feel like the hideous mess that C++ has turned into, with oodles of implicit behavior, un-greppable code, generated symbol names leading to compiler error hell, and a thousand different colors for your bike shed.
We already have languages that can do all this stuff. I welcome making changes to C to make it nicer to program in C, but I'm wary of proposals that turn C into yet another messy kitchen sink programming language. Let's keep it clean, obvious, and simple, please.
[1] https://news.ycombinator.com/item?id=41291206
[2] https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3266.htm#u...
Already there you can guess what features are on WG14 roadmap without going to the ISO mailings.