Over the last year or so, I have spent some time exploring some systems programming languages (or maybe you wouldn’t call them systems programming languages, but I’m a web developer and this is what you get). I have been able to play around with a couple of the more popular languages and explore some of their concurrency paradigms. Below is my entirely biased and unfair comparison of the languages and their concurrency support. I do not have extensive knowledge of any of the languages, and for the most part, but you can see some of my programs here
Before you say it, I know this comparison is not exactly fair. C is ~50 years the elder of these programming languages. In fact, it’s thread support was an afterthought (~1996). However, I still think it makes a great baseline model for modern paradigms to be understood in comparison.
There is probably quite a few ways to introduce parallel code execution in C, but for this post I will be referring to POSIX threads (or pthreads in C).
We start here with the snippet above. You will notice a very simple model for this matrix multiplication program. The program will perform matrix multiplication THREAD_COUNT rows at a time. However, the code is rather straightforward and a good paradigm for threaded programs. The threads are created, instructed to run a function, and then the second loop will wait for the threads to signal completion, before finishing the execution of the main program. This model will be used for all the programs throughout this post.
Now, many of you may have been wondering about the comment in the multiply function // Lock ij. Yes, it's commented out, and as the current program reads, unnecessary. However, it is there for a reason. It's to bring your attention to the nature of concurrency problems. The result_matrix is currently not truly being shared. And by this, I mean, that result_matrix is instantiated prior to any threads accessing it, and no threads are reading/writing to memory in the array that another thread may also be accessing. However, this is due to the simple nature of our program, not the carefulness of our programming. For instance, you can imagine a future program where we would like to complete several matrix multiplications and add them all together for a result matrix. We would not be setting a value result_matrix[i][j] = sum;
, but instead updating that value result_matrix[i][j] += sum;
, and this operation requires both a read and write of that value. Other threads that have completed a multiplication, may be simultaneously trying to read/write that value. This, would then absolutely require a semaphore, or lock, of that value before the read/write of the value. I point this out for some simple perspective. There is no problem with our C program, as exists, but is certainly not future-proofed and even might make some of you pretty uncomfortable.
Now, let's shift focus a little bit. POSIX threads are a system API, and may not be the pinnacle of concurrent C programs. However, they are a good start for us, and help us get to understanding the paradigms of some modern concurrency models (Go, Rust).
Now, let's take a look at a Go matrix multiplication program.
Looking through the above snippet, I am hoping that you recognized a few things. One, that looks like much the same program as the C program. And two, that's pretty simple!. As for the latter, Go is very much intended to be a simple language, but now we have a little understanding of what its hiding from us. You can see above that Go has a built-in chan type.
There is not much new here. As compared to the C main function, there is two loops. The first will initialize threads, kick off the multiply method, and save the threads in an array. The second loop will then ensure that the threads complete, and return a boolean value, before we terminate the program.
This brings us to something new. Go has a very widely publicized epithet "Share Memory By Communicating". Both the Go and C programs above work by sharing a global result_matrix, which we have previously mentioned, will likely need the introduction of a mutex for future safety in read/writes. However, think again about the above Go mantra. Share memory by communicating... To me, I'm thinking we can greatly improve the readability and understanding of this program, by having each call to multiply return the result of the multiply, rather than writing to a global array and returning when done. This way, the caller function can do all the writing to the result_matrix. Regardless, of whether we think this would be a nice improvement to our program, this is a non-trivial paradigm shift. We should embrace the idea that channels return values and try to understand how that can improve our concurrent programs.
Rust is a very interesting language, and it takes some getting used to. But it does play by the existing rules. It also embraces the Go methodology of sharing memory by communicating (as opposed to sharing memory to communicate). However, whereas Go seems to focus on making concurrency plainly simple, Rust seems to focus on abstracting away the pitfalls of concurrency. It forces you to understand what you are doing, and think critically about writing concurrent programs. What I mean by this is: Rust won’t let you do unsafe things. Trying to share a mutable reference across threads will simply not work, and it really puts that into perspective. In an age of pipelined processors and multiple cores, why should it work! Rust does this to make sure we can never get into problems, even though, as previously stated, it is not problematic for our current case.
Compare the above code to our C and Go programs. It’s behavior is much the same, but Rust is able to guarantee the safety of this code execution. If we tried to run this code without our Mutex locks, Rust would have a fit. If we tried to just pass a mutable result matrix to our function, it just wouldn’t let us. Whereas, Go would decide we actually wanted thread safe variables but didn’t specify, or C would just let us proceed with our flawed program. Every step of the way, Rust makes us ensure a safe, unlocked value, and is ready to complain if that's not the case.
Now here's where I say the winner and why you should use it. I'm sorry, of course I won't. Each of these languages/implementations brings something fresh to the table. And that's the brilliance of it. It's why we should all continue to learn new things. I will tell you that the Rust implementation took me at least 3 times as long as the other implementations, but it probably taught me the most. So, go out there, try them all and make yourself better because of it.
Comments