Hacker News new | ask | show | jobs
by zozbot234 1516 days ago
> Go's inane hostility toward FFI calls got in my way several times.

All languages w/ obligate GC are "hostile" to FFI in some way or another. The Go default implementation also uses split stacks or something for its goroutines, that cannot feasibly interop with FFI code. But it's usually easy enough to just isolate Go code to it's own process/address space and use IPC or network communication to enable the interop one would usually achieve via FFI.

3 comments

Inversely, virtually all languages with "easy FFI" end up being even more hostile in that a significant chunk of the ecosystem depends on C build tooling which is almost always fragile: C build systems have implicit dependency management, so you don't know what dependencies you need to have installed on your system or where they need to be installed. This means that something which builds on one machine may fail to build on another machine (in the case of build-time dependencies) or that it may run on one machine but not another (in the case of run-time dependencies). It's also opaque to the host build system, so cross compilation becomes dramatically more difficult. Lastly, C is inherently unsafe and insecure in ways that most host languages are not.

In practice, whether by accident or design, the Go ecosystem is really, really nice because it avoids FFI to a high degree. An overwhelming majority of programs can be cross compiled into a truly static binary (it may not even depend on libc unless--as is the case with Windows and MacOS--the host platform requires it). It also means that there are very few "C-shaped libraries", by which I mean thin bindings around some C library which exposes idiomatic C semantics rather than idiomatic Go semantics. Moreover, your programs aren't running a bunch of inherently unsafe code under the hood, and are consequently more likely to be secure as a result.

It's kind of nice that C FFI is possible such that libraries which are unlikely to be ported to Go (e.g., ffmpeg) or which cannot be ported to Go (e.g., opengl) are still available, but not so easy that people pull in C libraries for every little convenience.

The Rust ecosystem does one better and packages the C libraries and build configuration (including making it portable across platforms) as part of the crate. So you just add the dependency to your Cargo.toml and the C library will build as part of the regular `cargo build` process.
Unless something has changed in the relatively recent past, I think you're overselling a fair bit. Not only does the package author have to understand the C dependency well enough to package it correctly on all platforms (basically by verifying the build in a hermetically sealed environment, and who is doing that?), but also the process for cross compiling is (or at least was) pretty complicated: https://www.modio.se/cross-compiling-rust-binaries-to-armv7..... And even then, I'm not sure this will yield a truly static binary (i.e., no dependency on libc).

In Go, it's just `CGO_ENABLED=0 GOARCH=armv7 GOOS=linux go build` for pure Go programs.

> (basically by verifying the build in a hermetically sealed environment, and who is doing that?)

Lots of people run stuff in CI, which isn't exactly that, but is close enough to make it not as big of a pain as it might otherwise be.

It can also help if their docs aren't great; I've looked at CI configs to realize how to install some sort of system dependency before.

> but also the process for cross compiling is (or at least was) pretty complicated

Most of this article is talking about installing and setting up both Docker and a C cross-compiled toolchain. So, you're right, but also not, sorta kinda. That is, this is certainly more hard than Go, but we're not talking about pure Rust at this point, so the fair comparison would be cgo with some C dependencies, which would also involve setting up a C cross-build toolchain, (and maybe docker). But at the same time, it doesn't have to be this way: Zig includes a full C cross toolchain in its compiler, so that you don't have to do this installation. It is, in my opinion, currently best-in-class here, far surpassing both Go and Rust.

It is also worth nothing that, IIRC, Go had to switch to dynamically linking libc on many platforms, since the idea of a "fully statically linked binary" is basically only coherent on Linux.

> Lots of people run stuff in CI, which isn't exactly that, but is close enough to make it not as big of a pain as it might otherwise be.

CI has a whole lot of variation. On the extreme end, there are people running Jenkins jobs on the same hosts as other jobs, and everyone just pre-installs whatever they need onto the base image for the host (i.e., not even working with a fresh OS image). Moreover, many people are just going to run their CI on amd64 Debian or RHEL and assume it works for all targets.

> That is, this is certainly more hard than Go, but we're not talking about pure Rust at this point, so the fair comparison would be cgo with some C dependencies

My whole thesis here is that Go leans less on FFI than other ecosystems, so you shouldn't need CGo in most cases where you would have to use FFI in other languages. It's a lot easier to get a pure-Go dependency tree than it is a pure-Rust dependency tree. Of course, that's an emergent property derived from weaknesses of Go's FFI, but it ends up being a really nice property in practice.

> But at the same time, it doesn't have to be this way: Zig includes a full C cross toolchain in its compiler, so that you don't have to do this installation. It is, in my opinion, currently best-in-class here, far surpassing both Go and Rust.

I think this is true if you assume that all ecosystems lean on C equally, but it's better by far to depend on C less because including the C cross toolchain doesn't absolve you from humans packaging C dependencies (in which case it's either easy because you neglect a bunch of packages or you test in a hermetic environment a la Nix and it becomes more bothersome than maintaining a pure-$hostLang version of the same package).

> Moreover, many people are just going to run their CI on amd64 Debian or RHEL and assume it works for all targets.

Rust has a strong concept of "tiered platforms", and so lots of people support at least Mac/Windows/Linux. Nobody uses Jenkins (for open source packages that will become your dependencies, at least), they use GitHub Actions or CircleCI, which make it easy to support many platforms. I personally run Windows, no WSL, and 99.99% of the time, everything Just Works for me.

But yes, that's why I wasn't saying it's purely just as good. For sure. But it does generally work well.

> My whole thesis here is that Go leans less on FFI than other ecosystems,

Gotcha, that's fair. Pure-x for any x often is really, really nice! Full agreement there.

While this is true in many cases, it’s worth pointing out that that is the choice of the package author, and is not always super simple to implement. So yeah, much of the time it is nice, but you’ll sometimes also run into these classic sorts of issues, either because the authors do not put in that work or because there’s a bug in the implementation.
And as a bonus, be statically linked with all the benefits that brings.
I recall statically compiling Rust to be a big pain (like, actually statically compiling, no dynamic dependencies on libc in Linux). I assume this is all the more true with arbitrary C dependencies?
If you have no C dependencies, it’s simple: you ask for the musl libc, and you’re done. It is not more onerous than go.

If you have C dependencies, then it does become a pain, depending on how well those dependencies' -sys packages interface with whatever build system they use.

I'm not talking about an actual static binary, I'm talking about a typical "mostly static" binary with the handful of common dynamic dependencies.
Serializing a request structure, making an IPC/network call, deserializing the request structure, serializing the response structure, sending it back, and deserializing it ... isn't really a solution when the purpose of an FFI call is typically to fix some performance issue.

Lots of garbage-collected languages make FFI not only easy but plenty fast. Go does neither.

I started out thinking that fast and easy FFI was ideal and being disappointed that Go's FFI was neither. I've since changed my opinion as it's really nice that one can usually get away without pulling any C dependencies into their dependency tree. I wrote more in the sibling comment: https://news.ycombinator.com/item?id=31194347
That would be great if Go provided better performance. With its awful FFI, you have no recourse when you hit its limits other than to rewrite the entire codebase in something else.

As with many things, there's nothing stopping you from just sticking with pure Go if you don't like C toolchains. While C build issues are a valid theoretical concern, in practice I've never had any Python package fail to install because of a C dependency problem that wasn't trivially resolved, nor any Rust project fail to compile because of a C dependency problem at all.

> That would be great if Go provided better performance. With its awful FFI, you have no recourse when you hit its limits other than to rewrite the entire codebase in something else.

I wouldn't know. I've never run into an issue where Go's performance was a real bottleneck, and anyway every mainstream language with easy FFI still has significant FFI overhead (so much so that many programs actually run slower with FFI). This isn't really true for Rust (Rust makes it easy to define types which are essentially C structs and thus require little/no marshaling), but performance also isn't the reason you FFI out of Rust.

> As with many things, there's nothing stopping you from just sticking with pure Go if you don't like C toolchains.

Right, that's my point. You viably can stick with pure Go because such a large share of the Go ecosystem is pure because FFI is rarely worth the hassle.

> While C build issues are a valid theoretical concern, in practice I've never had any Python package fail to install because of a C dependency problem that wasn't trivially resolved

Try building a significant Python project on anything except a recent version of RHEL, Debian, MacOS, or Windows. For example, try getting your Python project running on something like a scratch Docker container. Or try packaging a Python package (which depends even transitively on a C library, especially one which isn't already packaged for Nix) with Nix.

> every mainstream language with easy FFI still has significant FFI overhead (so much so that many programs actually run slower with FFI). This isn't really true for Rust

This isn't true for almost any language to the extent it's true for Go, and for many compiled languages it isn't really true at all.

> Try building a significant Python project on anything except a recent version of RHEL, Debian, MacOS, or Windows. For example, try getting your Python project running on something like a scratch Docker container. Or try packaging a Python package (which depends even transitively on a C library, especially one which isn't already packaged for Nix) with Nix.

While these are legitimate theoretical problems, none of them are really problems in practice. Containers don't need to be scratch, and if you're building a Python project, you're already not running a scratch container, so the addition of FFI doesn't change that. Nix is not an environment I've ever seen a requirement to support, let alone had a requirement.

It really seems to me that you like Go and you like Go's design decisions, but "I like this" is not the same as "this is better than that". I'm not particularly interested in rehashing the same conversation over and over again.

> This isn't true for almost any language to the extent it's true for Go

Go's object structure is much closer to C's. In many cases, it's just casting pointers (e.g., you can convert a Go slice to a C array by taking a pointer to its first element and casting it to a pointer of the C element type, provided the element types are binary-compatible). This means fewer allocations than a language like Java or Python where you would have to allocate a new slice and chase pointers around the heap for every field in every element in the array. In my experience, most of Go's overhead comes from function call bookkeeping at the FFI boundary rather than marshaling data.

> for many compiled languages it isn't really true at all.

I'm pretty sure it's still true for many compiled languages (e.g., Java, Haskell, etc).

> While these are legitimate theoretical problems, none of them are really problems in practice.

They were problems for me in practice.

> Containers don't need to be scratch, and if you're building a Python project, you're already not running a scratch container, so the addition of FFI doesn't change that.

Containers don't need to be scratch, but they often need to be lightweight with low cold-start latencies (including pulling). Not having to pull in a whole distro is advantageous. And a pure-Python project certainly could run in a scratch container (i.e., a container with just the interpreter, the program, and the program's transitive dependencies).

> Nix is not an environment I've ever seen a requirement to support, let alone had a requirement.

"Reproducible builds" is the requirement. Our customers' security teams vetted our dependencies individually, and if a dependency changed we would have to have that dependency re-vetted.

> It really seems to me that you like Go and you like Go's design decisions, but "I like this" is not the same as "this is better than that".

Clearly, no one here is conflating those things. I'm arguing that Go has a particular strength, not that that strength is the only factor.

> I've since changed my opinion as it's really nice that one can usually get away without pulling any C dependencies into their dependency tree.

That trophy is owned entirely by the Java ecosystem. Thanks to that, once Loom arrives, basically the whole ecosystem will automagically become reactive-aware.

> The Go default implementation also uses split stacks or something for its goroutines

This has not been true since Go 1.2, back in late 2013.

The fact remains that you need a separate implementation (cgo) if you want to do FFI. It might be something else goroutine-related that blocks FFI in the default Go implementation, but the issue is still there either way.
What do you mean "a separate implementation"? CGo is part of Go, it's not another implementation of Go.
Separate in the "CGo is not Go" sense.
FFI isn't important. It's a niche feature for a superminority of use cases.