Block-encompassing macros in C

It is quite a frequent pattern in programming where before executing a bunch of code, you need to do some setup and after the code is executed, you need to do some cleanup. Examples include acquiring and releasing locks, logging profiling information at the beginning and end of a block etc. Let's use locks as a running example here.

Our goal is to write a macro to transform this code:
 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
/* Setup */
bool lock_acquired_now;
if (lock_held_by_current_thread ()) {
  lock_acquired_now = false;
} else {
  lock_acquire (filesys_lock);
  lock_acquired_now = true;
}

/* Actual work */
bool success = file_write_at (P_FILE (p),
                              p->kaddr,
                              P_READ_BYTES (p),
                              P_OFFSET (p));
if (success) {
  p->page_status = FILESYSTEM;
  p->is_loaded = false;
} else {
  p->page_status = MEMORY;
  p->is_loaded = true;
}

/* Clenup */
if (lock_acquired_now) {
  lock_release (filesys_lock);
}

Into this one:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use_lock (fs_lock) {
  bool success = file_write_at (P_FILE (p),
                                p->kaddr,
                                P_READ_BYTES (p),
                                P_OFFSET (p));
  if (success) {
    p->page_status = FILESYSTEM;
    p->is_loaded = false;
  } else {
    p->page_status = MEMORY;
    p->is_loaded = true;
  }
}
Short and sweet, isn't it? That's why such things are called syntactic sugar!

If you are eager to see the macro right away, you can directly jump to it; but here's a bit more on why one would want such a thing in the first place and how one would go about solving the problem...

Let's look at the most basic example, acquiring and releasing a lock:
1
2
3
4
5
6
lock_acquire (lock);
int i = some_function ();
stmt1;
if (condition) do_this ();
else do_that ();
lock_release (lock);
It is already non-trivial that both the acquiring and releasing are paired. Every place in your code where you need a lock, you would need to enclose the critical section between a pair or statements. Compare this to our end-goal: we want obvious, recognizable block structure, which leads to nice indentation and readability too.

Also, in the above example, it did not seem like too much of a repeated pattern to warrant the use of a macro. After all, just two additional statements, right? Now, think of a case when you might already have the lock. You can modify the acquire function such that it returns immediately if lock is already held. Yet, we have a problem. The release statement on line 6 needs to know whether the lock was acquired in line 1 or it was held from before. In the latter case, it would be wrong to release the lock at line 6. This brings us to the necessity of coordination between the setup and cleanup functions.

If you remember, re-entrant locks is exactly what the first snippet in this post does. For a seven line critical section, we have a total of fourteen lines of code just for setup and cleanup. Now just imagine needing to do this, say, twenty times in a file! Here the elegance of the macro approach begins to shine.

Observing the desired usage pattern carefully, we see that the critical section is not an argument to the macro. The macro has no access whatsoever to that code block, and yet, we want to do something before any statement in the block executes and do something more after the entire block finishes execution.

We can simply insert some statements before the block. How to insert some statement after a block using a macro? No. There's no way of doing it. Oh, wait... why do we need to insert code after the block? Could we somehow specify that certain code should be run after the block without actually putting it after the block?

Yes! Think of a for-loop which just runs for one iteration: how about cramming the setup code into the loop initialization part and the cleanup code into the loop-increment part? Because the loop runs exactly once, the loop-increment part runs exactly once too: at the end of the first iteration. That is, at the time when the entire block has been executed once. That's what we do in the solution:
 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
#define MERGE__(a,b)  a##b
#define LABEL__(a,b) MERGE__(a, b)
#define UNIQUE(var_name) LABEL__(var_name, __LINE__)

#define use_lock(lock)                              \
static bool UNIQUE (__exit_done) = false;           \
static bool UNIQUE (__acquired_now);                \
static int UNIQUE (__inner_loop_index);             \
static void UNIQUE (__entry_func) (void) {          \
  if (lock_held_by_current_thread (lock)) {         \
    UNIQUE (__acquired_now) = false;                \
  } else {                                          \
    lock_acquire (lock);                            \
    UNIQUE (__acquired_now) = true;                 \
  }                                                 \
}                                                   \
static void UNIQUE (__exit_func) (void) {           \
  if (UNIQUE (__acquired_now)) lock_release (lock); \
  UNIQUE (__exit_done) = true;                      \
}                                                   \
for (UNIQUE (__entry_func) ();                      \
     UNIQUE (__exit_done) == false;                 \
     UNIQUE (__exit_func) ())                       \
for (UNIQUE (__inner_loop_index) = 0;               \
     UNIQUE (__inner_loop_index) < 1;               \
     UNIQUE (__inner_loop_index) += 1)

Why the "UNIQUE" macro?
The UNIQUE macro just appends the line number to its argument. If we don't do that, then within a scope, our macro can be used only once. If we try to use it twice, the compiler will complain of variables being defined more than once.

Why the inner for-loop?
Have a look at this code:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use_lock (fs_lock) {
  bool success = file_open (filename);
  
  free (filename);
  
  if (success) break;
  else {
    print_error_message ();
    log_attempt_number ();
  }
}
If the condition on line 6 is true, then we want to break out of the critical section immediately. But we still need to execute the cleanup part, right? With our nested for-loop approach, the break just breaks out of the inner for-loop and brings us to the end of outer for-loop, at which point, cleanup code will be executed. (Darwin, thanks a lot for this!)

Finally, here's the code this macro produced for the example we began with (manually indented for readability):
static bool __exit_done184 = false;
static bool __acquired_now184;

static void __entry_func184 (void) {
  if (lock_held_by_current_thread (fs_lock)) {
    __acquired_now184 = false;
  } else {
    lock_acquire (fs_lock);
    __acquired_now184 = true;
  }
}

static void __exit_func184 (void) {
  if (__acquired_now184) lock_release (fs_lock);
  __exit_done184 = true;
}

for (__entry_func184 ();
     __exit_done184 == false;
     __exit_func184 ())
for (__inner_loop_index184 = 0;
     __inner_loop_index184 < 1;
     __inner_loop_index184 += 1)
{
  bool success = file_write_at (P_FILE (p),
                                p->kaddr,
                                P_READ_BYTES (p),
                                P_OFFSET (p));
  if (success) {
    p->page_status = FILESYSTEM;
    p->is_loaded = false;
  } else {
    p->page_status = MEMORY;
    p->is_loaded = true;
  }
}

Useful resources:
  1. Token-parsing (##) preprocessor macro usage example.
  2. Tip: gcc -E filename.c outputs C code where all the macros are expanded.

3 comments :

  1. Neat! Although, I find macros make debugging a pain. I achieve something similar by wrapping raw locks around a custom object that sets them up in the constructor and releases them in the destructor. That way, I just initialize my custom lock in a scoped critical section and it gets released when it goes out of scope. Just a thought.

    Reply Delete

Post a Comment

Note: Only a member of this blog may post a comment.