A Tale of Two Threads (APIs)
Paul J. Lucas

Paul J. Lucas @pauljlucas

About: C++ Jedi Master

Location:
San Francisco Bay Area
Joined:
Jan 21, 2017

A Tale of Two Threads (APIs)

Publish Date: Jul 21
2 0

Introduction

Even though nascent “threads” appeared as early as 1966, they weren’t supported by any major programming language of that era. POSIX threads, aka pthreads (“pea-threads”), didn't appear until 1995.

Consequently when C was created in 1972, it didn’t support threads at all until pthreads came along. Pthreads is widely available on any Unix system and even Microsoft Windows ports exist.

Among other things, C11 added standard library support for threads. Its API is necessarily superficially different from and a subset of pthreads.

While that’s not terrible, it’s unfortunate; more unfortunate is that standard threads are optional for implementations. Specifically, if the compiler predefines the __STDC_NO_THREADS__ macro, then standard threads are not supported. Hence, if you’re writing cross-platform software, you can’t rely on standard threads being available.

So what should you do? You certainly don’t want to use #ifdef __STDC_NO_THREADS__ all over and write the same lines of threading code twice: once using the standard API #else using the pthreads API. You have two choices:

  1. Forget about standard threads and just use pthreads since it’s more widely supported. This is a perfectly reasonable choice.

  2. Implement a standard threads API wrapper around pthreads and use that if standard threads aren’t available. While such a wrapper is fairly trivial, it’s just more code for no real benefit.

Additionally, as mentioned, standard threads’ API is a subset of pthreads’ API. If you need to use any part of the pthreads API that’s not in the standard subset, you’re forced to use pthreads.

Despite these unfortunate realities, let’s implement such a wrapper.

Macros & Types

Defining macros and types that map from standard threads’s types to pthreads’ types is straightforward:

#define ONCE_FLAG_INIT  PTHREAD_ONCE_INIT

enum {
  mtx_plain     = 0,
  mtx_timed     = 1 << 0,
  mtx_recursive = 1 << 1
};

enum {
  thrd_success,
  thrd_busy     = EBUSY,
  thrd_nomem    = ENOMEM,
  thrd_timedout = ETIMEDOUT,
  thrd_error
};

typedef pthread_cond_t  cnd_t;
typedef pthread_mutex_t mtx_t;
typedef pthread_once_t  once_flag;
typedef int           (*thrd_start_t)( void* );
typedef pthread_t       thrd_t;
typedef void          (*tss_dtor_t)( void* );
typedef pthread_key_t   tss_t;
Enter fullscreen mode Exit fullscreen mode

Trivial Wrapper Functions

Most of the wrapper functions are one-liners that can be done inline, such as for cnd_signal:

inline int cnd_signal( cnd_t *c ) {
  return pthread_cond_signal( c ) == 0 ? thrd_success : thrd_error;
}
Enter fullscreen mode Exit fullscreen mode

Such implementations can also be done for cnd_broadcast, cnd_wait, mtx_lock, mtx_unlock, thrd_current, thrd_detach, thrd_equal, thrd_exit, thrd_yield, tss_create, tss_get, and tss_set.

Other wrappers have to map error codes, such as for cnd_init:

int cnd_init( cnd_t *c ) {
  switch ( pthread_cond_init( c, /*attr=*/nullptr ) ) {
    case 0     : return thrd_success;
    case ENOMEM: return thrd_nomem;
    default    : return thrd_error;
  }
}
Enter fullscreen mode Exit fullscreen mode

Incidentally, the above code also gives an example of how the standard API is a subset of pthreads. Specifically, in pthreads, conditions, mutexes, and threads can all be created with optional attributes.

Such implementations can also be done for cnd_timedwait, mtx_timedlock, and mtx_trylock.

There are a few functions in the standard threads API that have no return value even though their counterparts in the pthreads API return an error code, such as call_once vs. pthread_once. Granted, such pthreads functions fail only if you make a programming mistake, but ignoring errors is generally a bad idea. For such functions, we can do something like this:

void call_once( once_flag *flag, void (*once_fn)() ) {
  ASSERT_IS_0( pthread_once( flag, once_fn ) );
}
Enter fullscreen mode Exit fullscreen mode

where ASSERT_IS_0 is:

#ifndef NDEBUG
static void assert_is_0_err( char const *file, int line,
                             int expr, char const *expr_str ) {
  fprintf( stderr,
    "%s:%d: '%s' non-zero value %d: %s\n",
    file, line, expr_str, expr, strerror( expr )
  );
  abort();
}

#define ASSERT_IS_0(EXPR)        \
  do {                           \
    if ( unlikely( expr != 0 ) ) \
      assert_is_0_err( __FILE__, __LINE__, (EXPR), #EXPR )
  } while (0)

#else
#define ASSERT_IS_0(EXPR)  (EXPR)
#endif /* NDEBUG */
Enter fullscreen mode Exit fullscreen mode

(See here regarding unlikely.)

We can’t simply use assert because we always want the expression evaluated even when NDEBUG is defined. Plus it’s helpful to print the error value and string in the unlikely event the function fails.

Such implementations can also be done for cnd_destroy, mtx_destroy, and tss_delete.

The remaining functions require a bit more work.

mtx_init

Mutex initialization is handled slightly differently between the two APIs, specifically the way in which recursive mutexes are created, hence:

int mtx_init( mtx_t *m, int type ) {
  pthread_mutexattr_t attr, *pattr = nullptr;

  if ( (type & mtx_recursive) != 0 ) {
    pthread_mutexattr_init( &attr );
    pthread_mutexattr_settype( &attr, PTHREAD_MUTEX_RECURSIVE );
    pattr = &attr;
  }

  int const mutex_init_rv = pthread_mutex_init( m, pattr );

  if ( pattr != nullptr )
    pthread_mutexattr_destroy( &pattr );

  switch ( mutex_init_rv ) {
    case 0     : return thrd_success;
    case ENOMEM: return thrd_nomem;
    default    : return thrd_error;
  }
}
Enter fullscreen mode Exit fullscreen mode

thrd_sleep

One place where the standard API is not a subset of pthreads is that the former offers thrd_sleep whereas the latter has no equivalent. However, POSIX has nanosleep, hence:

int thrd_sleep( struct timespec const *duration,
                struct timespec *remaining ) {
  int const sleep_rv = nanosleep( duration, remaining );
  switch ( sleep_rv ) {
    case  0:
    case -1:
      return sleep_rv;
    default:
      return -2;
  }
}
Enter fullscreen mode Exit fullscreen mode

Creating & Joining Threads

The remaining two functions of thrd_create and thrd_join have a slightly different API that makes simple wrappers impossible. Specifically, in pthreads, the signature of the thread’s start function is void*(*)(void*) (returns void*) whereas in standard threads, the signature is int(*)(void*) (returns int). Why the difference? I don’t know. Regardless, we’ll need another data structure and function:

struct pthread_start_fn_data {
  thrd_start_t  start_fn;   // C11 thread start function.
  void         *user_data;  // Thread user data.
};

static void* pthread_start_fn_thunk( void *p ) {
  struct pthread_start_fn_data const data =
    *(struct pthread_start_fn_data*)p;
  free( p );
  return (void*)(intptr_t)data.start_fn( data.user_data );
}

int thrd_create( thrd_t *t, thrd_start_t start_fn, void *user_data ) {
  struct pthread_start_fn_data *const data =
    malloc( sizeof( struct pthread_start_fn_data ) );
  *data = (struct pthread_start_fn_data){ start_fn, user_data };

  int const create_rv = pthread_create(
    t, /*attr=*/nullptr, &pthread_start_fn_thunk, data
  );
  if ( create_rv == 0 )
    return thrd_success;

  free( data );
  return create_rv == EAGAIN ? thrd_nomem : thrd_error;
}
Enter fullscreen mode Exit fullscreen mode

Since we want to call a thrd_start_t that has a signature different from pthread’s, we’ll use pthread_start_fn_data to remember the thrd_start_t and the user data (if any), then give pthread_start_fn_thunk to pthread_create. Note that pthread_start_fn_data must not be a local variable of thrd_create since pthread_start_fn_thunk might not start executing until after thrd_create returns at which point local variables no longer exist. Hence, we’re forced to malloc and free it.

The implementation of thrd_join has to convert the void* returned by pthread_join to the int of thrd_join:

int thrd_join( thrd_t t, int *pthrd_value ) {
  void *pthread_value;
  if ( pthread_join( t, &pthread_value ) != 0 )
    return thrd_error;
  if ( pthrd_value != nullptr )
    *pthrd_value = (int)(intptr_t)pthread_value;
  return thrd_success;
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

C’s standard threads’ API is (mostly) a subset of pthreads’ and is optional, even for conforming compilers. That’s problematic for software that needs to compile on many platforms. However, as demonstrated, you can implement a wrapper if you really want to use the standard API.

Epilogue: What about C++?

At around the same time as C11, C++11 also added standard library support for threads, but the C++ committee made it required, not optional like C. Of course that means you can rely standard threads being supported on all platforms that support C++. C++ standard threads’ API is also more fully featured than C’s. Hence if you’re programming in C++, choosing to use its standard threads is the reasonable choice.

Comments 0 total

    Add comment