Stratus3D

A blog on software engineering by Trevor Brown

Performance of Elixir's System.get_env/0 Function

At work I was debugging a performance issue in one of our Elixir applications and stumbled across the strange implementation of Elixir’s System.get_env/0 function. In this blog post I’ll show how it caused performance issues for the application I was debugging and I’ll also propose a better implementation of the function. I’ll conclude by explaining why the better implementation is not used yet.

Background

The response times for the application I was debugging had suddenly gotten slower and nobody was sure why. Some configuration changes had recently been made, but none of the changes should have degraded the performance as much as they did. Response times from the HTTP API were 2-3 times slower than they were prior to the configuration change. I ran eflame on the Elixir request handling code and found the culprit - all of our time was spent in calls to System.get_env/0. The flame graph revealed that we were calling System.get_env/0 several dozen times per request. While odd, it was an easy thing to fix. I changed the code so that System.get_env/0 was invoked once at startup, and the values we needed were stored in the application environment and were then retrieved by the code that originally called System.get_env/0. The environment variables are never changed while this application is running so caching the values was safe to do.

After the fix was made response times were about 3 times faster than they had ever been for this particular HTTP API. While I knew the dozens of calls to System.get_env/0 were the problem I began to wonder why this particular function call was so slow. The request handling code did lots of things when handling these requests. Why did System.get_env/0 become the bottleneck?

The Peculiar Implementation of System.get_env/0

Here is the implementation of System.get_env/0 for Elixir 1.9:

1
2
3
4
5
6
7
8
@spec get_env() :: %{optional(String.t()) => String.t()}
def get_env do
  Enum.into(:os.getenv(), %{}, fn var ->
    var = IO.chardata_to_string(var)
    [k, v] = String.split(var, "=", parts: 2)
    {k, v}
  end)
end

The implementation is fairly simple but it is surprising to see the code splitting the binary on the = character. It is not clear why this is needed. Let us look at Erlang’s :os.getenv/0 function:

-spec getenv() -> [env_var_name_value()].
getenv() ->
    [lists:flatten([Key, $=, Value]) || {Key, Value} <- os:list_env_vars() ].

Here we can see that :os.getenv/0 combines the environment variable name and value with the equals sign used as the delimiter between them. os:list_env_vars() fetches the environment variables and returns a keyword list (or proplist as they are called in Erlang). So Erlang’s os:getenv/0 combines the variable name and value and then Elixir’s System.get_env/0 turns around and splits them apart again! The Elixir function undoes all the work that Erlang’s os:getenv/0 function does! This extra work makes this code slower than it needs to be, and because these operations are performed for each variable, the performance of the function is tied inversely to the number of environment variables present.

A Better Implementation

With what we know about the System.get_env/0 code we can easily create a better implementation. A simpler solution would be to use :os.list_env_vars/0 directly:

1
2
3
4
@spec get_env() :: %{optional(String.t()) => String.t()}
def get_env do
  Enum.into(:os.list_env_vars(), %{})
end

This implementation is not used because :os.list_env_vars/0 is not a documented function.

Performance Comparison

Comparing the performance of both implementations using Benchee reveals that the improved version is indeed faster, although both implementations are probably fast enough for most uses:

Operating System: Linux
CPU Information: Intel(R) Core(TM) i7-5500U CPU @ 2.40GHz
Number of Available Cores: 4
Available memory: 7.52 GB
Elixir 1.9.2
Erlang 23.0.3

Benchmark suite executing with the following configuration:
warmup: 2 s
time: 5 s
memory time: 0 ns
parallel: 1
inputs: none specified
Estimated total run time: 14 s

Benchmarking Improved get_env...
Benchmarking System.get_env...

Name                       ips        average  deviation         median         99th %
Improved get_env       25.48 K       39.24 μs    ±58.36%       27.82 μs      122.28 μs
System.get_env          4.14 K      241.46 μs    ±21.67%      239.08 μs      407.34 μs

Comparison:
Improved get_env       25.48 K
System.get_env          4.14 K - 6.15x slower +202.21 μs

This was run on my laptop with 90 environment variables set.

Conclusion

I created ticket on the Erlang issue tracker to make :os.list_env_vars/0 or a similar function public and documented. A PR has been created for this. Hopefully the next version of Erlang will include these changes.

Until new versions of Erlang and Elixir are released with the improved implementation it is best to assume System.get_env/0 calls are going to be slow. If you need faster performance it is probably best to cache the results of the first System.get_env/0 in application config like I did. Of course you’ll need to make sure the environment variables aren’t going to change if you go this route.