Memory safety is a property of a programming language that helps to prevent bugs in programs written in that language. This article describes Chapel’s memory safety features and how these features support Chapel’s goals of productivity and performance.
Chapel is designed to balance productivity, performance and scalability. As a result, its memory safety features are not as comprehensive as Python’s (where performance is not as important) or Rust’s (which has a design that focuses primarily on safety).
This table shows how we see Chapel as comparing to the other technologies studied here:
Productivity | ➖ | ✔️ | ➕ | ➖ | ➖ | ➕ |
Performance | ➕ | ➕ | ➖ | ➕ | ➕ | ➕ |
Scalability | ➕ | ➕ | ➕ | |||
Safety | ➖ | ➕ | ➕ | ➖ | ➖ | ✔️ |
Key: ➕: great; ✔️: good; ➖: drawback
Since this article is focused on the safety aspect, we’ll consider how Chapel compares to Rust, C, C++, and Python when it comes to common memory-safety programming errors. The following table shows the errors we will discuss and summarizes how each language does:
Variable Not Initialized | ❌ | ❌ | ✅ | ✅ | ✅ |
Mishandling Strings | ❌ | ⚠️ | ✅ | ✅ | ✅ |
Use-After-Free | ❌ | ⚠️ | ⚠️ | ✅ | ⚠️ |
Out-of-Bounds Array Access | ❌ | ❌ | ✅ | ✅ | ⚠️ |
Here are the meanings of ❌, ⚠️ , and ✅ for the purposes of this article:
- ✅ : The language prevents this type of error or responds to the error
- ⚠️ : There is significant help from the language to avoid this type of error; however, programmers still need to take caution because such errors can cause undefined behavior in some situations
- ❌ : The language doesn’t offer much protection against this type of error, and such errors may result in undefined behavior
This article also evaluates out-of-bounds array accesses in the context of communication in distributed-memory programming with MPI, OpenSHMEM, and Chapel. This table summarizes the result:
Out-of-Bounds in Communication | ❌ | ❌ | ⚠️ |
Variable Not Initialized
Many programming languages provide a way to declare a variable without initializing it. When using such a language, it’s a common error to forget to initialize a variable. What happens if you make that error? We’ll demonstrate it in this section with a program that declares a local variable but doesn’t initialize it.
C and C++
In C and C++, it’s easy to declare a variable without initializing it, as this example shows:
1 2 3 4 5 6 7 | #include <stdio.h> int main() { int x; // OOPS! x is not initialized printf("x is %i\n", x); return 0; } |
Unfortunately, programs like this in C and C++ print out stack trash, that is, whatever memory happened to be stored in the memory used for the variable:
This can lead to hard-to-find bugs and, in the context of software security, reveal information about a program to an attacker. The situation is a little better in C++ because, for many types that didn’t exist in C, variables using that type are automatically initialized. That applies to types like std::vector but not to the int used in this example, because int is a type that C++ inherited from C, and for which it needs to maintain compatibility.
Rust
Rust checks at compile-time that a variable is initialized before it is used. As a result, the program below won’t compile:
1 2 3 4 5 | fn main() { let mut x: i64; // OOPS! forgot to initialize x let y = x; println!("y is {}", y); } |
Note that Rust checks that local variables are initialized even in unsafe blocks.
Python
In Python, it’s just not possible to declare a variable; instead variables are created the first time they are assigned to. So, this type of error just isn’t possible.
Chapel
In Chapel, variables are initialized to a default value if the type supports it. Some variables can’t be initialized to a default value, and in those cases, the Chapel compiler will emit an error if the variable is used before it is initialized.
For example, an int variable will be initialized to 0:
1 2 3 4 | proc main() { var x: int; // integer variables are set to 0 if not initialized writeln(x); } |
A variable can’t be initialized to a default if it is declared without a type, or if its type has no default value.
Chapel allows variables to be declared without a type. In this case the variable’s type will be inferred when it is initialized. That won’t work if it’s used before it is initialized, so that case results in an error:
See https://chapel-lang.org/docs/language/spec/variables.html#split-initialization for more details on this feature.
A class type like owned C is an example of a Chapel type that has no
default value. Class types in Chapel can be nilable or non-nilable;
meaning they can store nil or not. The type owned C is non-nilable —
that is, a variable of that type can’t store nil. Since nil is the
reasonable default value for classes and owned C can’t be nil, the
variable
var x: owned C; can’t be initialized with a default. As a
result, the compiler will give an error.
We’ll discuss Chapel’s classes and memory management further in the Use-After-Free section.
Summary
How well do each of these programming languages protect against uninitialized memory?
- ❌ C and C++: programmer beware!
- ✅ Python: it’s not possible to write a variable declaration separate from initializing a variable
- ✅ Rust: checks at compile-time that each variable is initialized before it is used
- ✅ Chapel: ensures at compile-time that each variable is initialized, possibly by setting it to a default value
Mishandling Strings
General-purpose languages need to provide ways to manipulate strings, as strings are a very common data type. To demonstrate, we’ll create a little program in each language that creates a string storing a greeting:
C
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | #include <stdio.h> #include <string.h> #define MAX_GREETING 16 int main(int argc, char** argv) { char greeting[MAX_GREETING]; // C doesn't really have string support; // here we allocate an array to store the // greeting // OOPS! allocated array might not be big enough strcpy(greeting, "Hello "); // copy "Hello " into 'greeting' strcat(greeting, argv[1]); // append the passed name to the greeting printf("%s\n", greeting); return 0; } |
It’s easy to cause a stack overflow for this program by providing a name longer than 16 characters:
That’s disastrous from a security perspective, and it could be exploitable. In practice, C programmers should know not to write code like this. A better program would count the sizes of the strings to be concatenated, allocate a new string on the heap, and use stpncpy and strlcat instead of strcpy and strcat.
C++, Python, Rust, and Chapel
These newer languages have improved on the situation in C and include a standard string type that avoids many of the error-prone patterns of string manipulation in C. In particular, appending to a string will resize it appropriately.
Since C programs can be valid C++ programs, it’s possible to write an unsafe program like the above in C++ too.
Here are the equivalent programs using the standard string type in these other languages:
1 2 3 4 5 6 7 8 9 10 | #include <string> #include <iostream> int main(int argc, char** argv) { std::string greeting = "Hello "; greeting += argv[1]; // append the passed name to the greeting std::cout << greeting << std::endl; // print the greeting return 0; } |
1 2 3 4 5 6 7 8 9 | import sys def main(args): greeting = "Hello " greeting += args[1] print(greeting) if __name__ == "__main__": main(sys.argv) |
1 2 3 4 5 6 7 8 | use std::env; fn main() { let args: Vec<String> = env::args().collect(); let mut greeting = String::from("Hello "); greeting += &args[1]; println!("{}", greeting); } |
1 2 3 4 5 | config const who = ""; // enable command-line options like --who=world var greeting = "Hello "; greeting += who; writeln(greeting); |
Summary
C is uniquely bad at string manipulation, but the other languages provide mechanisms to avoid the most common issues.
- ❌ C: programmer beware!
- ⚠️ C++: It’s possible to write the same error-prone code with strcat since C++ extends C. Programmers should use the std::string type to avoid these issues.
- ✅ Everything else: the standard string type avoids these issues
Use-After-Free
When allocating memory dynamically, a potential problem is reading or writing memory that has already been freed.
C
C and C++ have a lot of flexibility with pointers. As a result, it’s very easy to read or write to memory that has been freed. This kind of error can cause all manner of problems, since the writes could overwrite other values in memory.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | #include <stdio.h> #include <stdlib.h> int main(int argc, char** argv) { int* buf = malloc(sizeof(int)); free(buf); buf[0] = 42; // OOPS: uses 'buf' after the memory is freed // do other heap operations to make heap corruption more visible { int* other_buf = malloc(sizeof(int)); other_buf[0] = 120; free(other_buf); } printf("buf[0] is %i\n", buf[0]); return 0; } |
What happens when you compile and run such a program? If you are lucky, it will crash. If you are unlucky, the error will cause a difficult-to-detect data corruption issue.
C++
C++ provides std::unique_ptr and std::shared_ptr to reduce the chances of a use-after-free because the free calls are automatically added. However, use-after-free is still possible.
For example, this program compiles, but it has a use-after-free:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | // compile with // g++ use-after-free.cpp --std=c++14 #include <iostream> #include <memory> int main(int argc, char** argv) { // allocate an integer on the heap auto buf = std::make_unique<int>(0); // create a reference that refers to the value on the heap int& ref_to_val = *buf; { // Replace the buffer with something else. // This causes the old buffer to be freed. buf = std::make_unique<int>(1); } ref_to_val = 42; // OOPS: uses 'buf' after the memory is freed // do other heap operations to make heap corruption more visible { buf = std::make_unique<int>(2); buf = std::make_unique<int>(3); } printf("buf[0] is %i\n", ref_to_val); return 0; } |
As with the similar C program, if you are lucky, the program will crash:
Python
Python is a garbage-collected language, and so isn’t susceptible to use-after-free errors. It keeps memory allocated as long as it can be referred to.
Rust
The Rust compiler issues an error in this case to prevent a use-after-free.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | fn main() { // Allocate an integer value on the heap let mut buf: Box<i32> = Box::new(1); // Create a reference to the value let ref_to_val: &mut i32 = &mut *buf; *ref_to_val = 10; // modify the value through the reference { buf = Box::new(2); // replace the pointer drop(buf); // explicitly drop 'buf' to avoid compiler error } println!("value: {}", ref_to_val); } |
Note that here, the borrow in the error message refers to the concept of having a pointer to something without having any concern about when that thing will be deallocated.
(The situation is different for unsafe code)However, code in unsafe blocks is not protected against use-after-free and can produce undefined behavior:
Chapel
Chapel provides automatic memory management for its types (arrays, strings, …) and owned and shared for class types to automatically manage freeing classes.
Chapel includes compile-time lifetime checking that catches common errors, but it is not exhaustive. It is designed to help programmers find problems in their programs without requiring a lot of programmer effort.
Here is an example of a program containing a use-after-free that is detected by Chapel’s lifetime checker. This program does not compile as a result.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | class C { var x: int; } proc main() { // create a reference to a 'C' instance on the heap var b: borrowed C? = nil; { var instance = new owned C(0); b = instance.borrow(); } b!.x = 42; // do other heap operations to make heap corruption more visible { var x = new owned C(2); var y = new owned C(3); } writeln(b); } |
In addition to owned, shared, and borrowed classes, Chapel supports unmanaged classes, which require the user to be responsible for freeing such memory when necessary, similar to classic C++. While this can be an important feature in some applications for generality and/or performance, its use is generally discouraged since it can potentially result in memory safety errors.
What are some errors that Chapel’s lifetime checker doesn’t detect?As mentioned, Chapel’s lifetime checker takes a hands-off approach to unmanaged. Using unmanaged is inherently unsafe but it is sometimes necessary. Here’s an example of a use-after-free that is not detected at compile-time because the use of unmanaged opts out of the checking:
Here is a case that has a use-after-free due to aliasing. This case goes beyond what we expect the Chapel compiler to handle.
Summary
The languages vary greatly in the extent to which use-after-free is an issue:
- ❌ C: use-after-free is very easy to accidentally write
- ⚠️ C++: unique_ptr and shared_ptr help to some extent, but use-after-free is still possible to write
- ⚠️ Rust: compile-time checking prevents a use-after-free in safe code, but a use-after-free is still possible in unsafe code
- ✅ Python: the garbage collector avoids this issue
- ⚠️ Chapel: automatic memory management for most types means that free need only be written when using unmanaged. The compiler can detect and emit errors for some use-after-free situations, but it does not detect all such situations.
Out-of-Bounds Array Access
Did you notice the bounds-checking errors in most of the string-greeting programs above? For example, the C program uses argv[1] but does not check that there was an argument passed. What does an out-of-bounds array access do in these languages?
C and C++
There is no array bounds checking in C or C++. In fact, it’s quite hard for a C compiler to provide array bounds checking, because, in practice, C code uses pointers as arrays, and there is not a consistent way for the compiler to know where the array length is stored.
For example, consider this program that creates a 1-element array and then accesses the ithi^{th} element based on a command-line argument:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | #include <stdio.h> #include <stdlib.h> int main(int argc, char** argv) { int idx = atoi(argv[1]); // convert the first argument into an int int array[1] = {0}; // allocate an array with space for 1 element int x = array[idx]; // access the array at 'idx' // OOPS! no bounds checking printf("array at index %i is %i\n", idx, x); return 0; } |
If the command-line argument is not 0, it will lead to an out-of-bounds array access. At best, you get a program crash. At worst, you get a hard-to-find memory corruption bug.
The story with a C++ vector is similar:
1 2 3 4 5 6 7 8 9 10 11 12 13 | #include <vector> #include <iostream> int main(int argc, char** argv) { int idx = atoi(argv[1]); // convert the first argument into an int std::vector<int> array(1); // create a vector with space for one element int x = array[idx]; std::cout << "array at index " << idx << " is " << x << std::endl; return 0; } |
C and C++ developers use address sanitizers or similar tools to find this class of error. Additionally, the C++ standard library in use might have a way to activate bounds checking for some types, such as -D_GLIBCXX_DEBUG.
Python
Since Python includes array bounds checking, a similar program with an out-of-bounds array access will cause an IndexError: list index out of range error to be raised.
1 2 3 4 5 6 7 8 9 10 | import sys def main(args): idx = int(args[1]) array = [0] x = array[idx] print("array at index", idx, " is ", x) if __name__ == "__main__": main(sys.argv) |
Rust
Rust includes bounds checking, and failing a bounds check will cause the program to panic (print out a message and halt).
1 2 3 4 5 6 7 8 9 | use std::env; fn main() { let args: Vec<String> = env::args().collect(); let idx = args[1].parse::<usize>().expect("need a number"); let array: [i32; 1] = [0; 1]; let x = array[idx]; println!("array at index {} is {}", idx, x); } |
Rust’s bounds checking is active even in unsafe blocks.
Chapel
For Chapel, out-of-bounds array accesses are checked by default, but disabled when the program is compiled with --fast or --no-checks.
When the program is compiled with bounds checks, the behavior is similar to the Rust program. The program halts and prints an error about the out-of-bounds access. For example, this program allocates a 10-element array and accesses an index provided on the command line:
1 2 3 4 5 6 7 | config const idx = 1; // enable command-line options like --idx=2 var array:[0..#10] int; // allocate an array with space for 10 elements var x = array[idx]; writeln("array at index ", idx, " is ", x); |
However, if the program is compiled with checks disabled, as with --fast, the out-of-bounds access causes undefined behavior. The program might crash, or it might print out garbage values.
Chapel’s array bounds checks are disabled with --fast in order to achieve maximum performance. The expectation is that such program errors will be found and resolved during development and testing where bounds checking will be enabled.
Summary
Bounds checking in these languages varies from opt-in to opt-out to always on:
- ❌ C and C++: out-of-bounds array accesses aren’t checked unless running with a memory-checking tool
- ✅ Rust: out-of-bounds array accesses cause the program to halt with an out-of-bounds error
- ✅ Python: out-of-bounds array accesses raise an error
- ⚠️ Chapel: with --checks (the default), out-of-bounds array accesses cause the program to halt; with --fast, these checks are disabled to improve performance
Out-of-Bounds Array Access in Distributed Memory
Chapel is a language designed for distributed-memory parallel computing, so we’ll compare Chapel with MPI and OpenSHMEM, which are distributed-memory parallel computing frameworks usable from C and C++.
What will happen with an out-of-bounds array access in the context of a distributed-memory parallel program?
C/C++ with MPI and OpenSHMEM
MPI and OpenSHMEM have a C interface that precludes bounds checking.
Here is a C and MPI example using MPI_Gather that provides a count too large that overflows the local buffer:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 | #include <stdio.h> #include <stdlib.h> #include <string.h> #include <mpi.h> int main(int argc, char** argv) { MPI_Init(&argc, &argv); int count = atoi(argv[1]); // convert the first argument into an int int myRank = 0; int numRanks = 0; MPI_Comm_rank (MPI_COMM_WORLD, &myRank); MPI_Comm_size (MPI_COMM_WORLD, &numRanks); size_t nPerRank = 1000; int* array = malloc(nPerRank*sizeof(int)); int* gathered = malloc(numRanks*nPerRank*numRanks*sizeof(int)); for (int i = 0; i < nPerRank; i++) { array[i] = myRank; } // Gather the first value from each array onto rank 0 MPI_Gather(/* sendbuf */ array, /* sendcount */ 1, /* sendtype */ MPI_INT, /* recvbuf */ gathered, /* recvcount */ 1, /* recvtype */ MPI_INT, /* root */ 0, /* comm */ MPI_COMM_WORLD); if (myRank == 0) { printf("Correct gather:\n"); for (int i = 0; i < numRanks; i++) { printf(" %i\n", gathered[i]); } } // What if there is an error in MPI_Gather? // If 'count' is larger than nPerRank, this version // will gather too much data and refer to invalid memory. MPI_Gather(/* sendbuf */ array, /* sendcount */ count, /* sendtype */ MPI_INT, /* recvbuf */ gathered, /* recvcount */ count, /* recvtype */ MPI_INT, /* root */ 0, /* comm */ MPI_COMM_WORLD); if (myRank == 0) { printf("Potentially bad gather:\n"); for (int i = 0; i < count*numRanks; i++) { printf(" %i\n", gathered[i]); } } MPI_Finalize(); return 0; } |
First, here’s what it looks like to compile and run it when there is no out-of-bounds access. In this case, any index less than 1000 will not lead to a memory safety violation.
Providing a count beyond the size of the array leads to incorrect results or core dumps:
Similarly, an out-of-bounds array access in OpenSHMEM might lead to incorrect results, hard-to-reproduce bugs, or core dumps:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | #include <stdio.h> #include <stdlib.h> #include <string.h> #include <shmem.h> int main(int argc, char** argv) { int idx = atoi(argv[1]); // convert the first argument into an int int myRank = 0; int numRanks = 0; shmem_init(); myRank = shmem_my_pe(); numRanks = shmem_n_pes(); size_t nPerRank = 1000; int* array = (int*) shmem_malloc(nPerRank*sizeof(int)); memset(array, 1, nPerRank*sizeof(int)); if (myRank == numRanks-1) { int val = 42; shmem_int_put(array + idx, &val, 1, 0); // OOPS: idx might be out-of-bounds shmem_int_get(&val, array + idx, 1, 1); // OOPS: idx might be out-of-bounds printf("Got value %#x\n", val); } return 0; } |
In this case, the value we got should be 0x1010101 if it is valid:
Providing an index beyond the array bounds leads to incorrect results and possibly core dumps:
Address sanitizers and similar tools are difficult to use in this context. Ideally, the network hardware provides support for shmem_int_put. As a result, the out-of-bounds access won’t necessarily even occur in code run by the processor! It’s likely to make the program halt, but it can be challenging to figure out what caused the out-of-bounds array access.
Distributed-Memory Programs in Chapel
A distributed Chapel program has the same level of bounds-checking support as a non-distributed Chapel program.
For example, this program creates a distributed array and then accesses the index provided on the command line (which might be out of bounds):
1 2 3 4 5 6 7 8 9 10 | use BlockDist; config const idx = 1; // enable command-line options like --idx=2 // create a distributed array storing 100 elements var array = blockDist.createArray(0..#100, int); var x = array[idx]; writeln("array at index ", idx, " is ", x); |
First, let’s show what happens if the index is in bounds:
If the index is not within bounds, you get an out-of-bounds error at run-time (provided it is compiled with bounds checking on, e.g., without --fast):
Summary
Bounds-checking errors when using MPI or OpenSHMEM cause undefined behavior and can be challenging to debug. In contrast, Chapel is unique in providing bounds checking for distributed-memory programming.
- ❌ MPI, OpenSHMEM: these distributed-memory programming frameworks can’t easily provide bounds checking due to their pointer-based C interface. Moreover, it can be challenging to debug these errors even when using an address sanitizer or similar tool.
- ⚠️ Chapel: with --checks (the default), out-of-bounds array accesses on distributed arrays cause the program to halt; with --fast these checks are disabled to improve performance.
Conclusion
Memory safety in Chapel provides for productivity, safety, and performance. Chapel is significantly safer than C and C++, and significantly safer than using MPI or OpenSHMEM for distributed-memory programming. Compared to Python, Chapel is able to achieve higher performance because it’s a compiled, statically-typed language, and it does not need a garbage collector. Compared to Rust, Chapel is able to provide safety when requested without requiring programmers to prove to the compiler that the code is correct.