Fundamentals of Resource Management in C

Double pointers are used to manage memory and data structures such as dynamic arrays of arrays. They are also usable when passing the address of a pointer to a function to modify it. An example of working with double pointers:

#include <stdio.h>
#include <stdlib.h>

void allocateArray(int **arr, int size, int value) {
  *arr = (int*)malloc(size * sizeof(int));
  for(int i = 0; i < size; i++) {
    *(*arr + i) = value;
  }
}

int main() {
  int *array = NULL;
  allocateArray(&array, 5, 45);  // выделяем память и инициализируем массив
  for(int i = 0; i < 5; i++) {
    printf("%d ", array[i]);
  }
  free(array);  // не забываем освободить память
  return 0;
}

Valgrind and sanitizers

Valgrind is looking for uninitialized memoryerrors in working with dynamic memory and much more, and after searching it reports errors.

For example:

#include <stdlib.h>

int main() {
    int *a = malloc(sizeof(int) * 10); // здесь мы выделили память...
    // ...и забыли ее освободить. Опечатка или судьба?
    return 0;
}

Running Valgrind with this code, we get something like:

==12345== LEAK SUMMARY:
==12345==    definitely lost: 40 bytes in 1 blocks

Sanitizers also help us detect memory leaks, without leaving the IDE. For example, AddressSanitizerone such tool, can be used as follows:

gcc -fsanitize=address -g your_program.c

This will prevent memory leaks from going unnoticed.

You can use features from C++ (what?)

As you may know, RAII is a pattern from C++ where when an object is created, a resource is captured, and when the object is destroyed, it is released. “But stop,” you say, “there are no classes or destructors in C!” However, let's try to adapt RAII for C using structures and cleanup functions.

Let's imagine that there is a structure for managing dynamic memory:

#include <stdlib.h>

typedef struct {
    int* array;
    size_t size;
} IntArray;

IntArray* IntArray_create(size_t size) {
    IntArray* ia = malloc(sizeof(IntArray));
    if (ia) {
        ia->array = malloc(size * sizeof(int));
        ia->size = size;
    }
    return ia;
}

void IntArray_destroy(IntArray* ia) {
    if (ia) {
        free(ia->array);
        free(ia);
    }
}

IntArray_create And IntArray_destroy play the roles of constructor and destructor.

Another interesting pattern is factory functions. They allow you to abstract from the process of creating objects, hiding the details of initialization. In C, these can be functions that return pointers to various structures representing resources:

typedef struct {
    FILE* file;
} FileResource;

FileResource* FileResource_open(const char* filename, const char* mode) {
    FileResource* fr = malloc(sizeof(FileResource));
    if (fr) {
        fr->file = fopen(filename, mode);
    }
    return fr;
}

void FileResource_close(FileResource* fr) {
    if (fr) {
        fclose(fr->file);
        free(fr);
    }
}

Parallelism and multithreading

Using pthreads, you can divide tasks into several threads that run in parallel. Let's create a thread to execute a simple function that prints a message to the screen:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

// функция, которая будет выполнена в потоке
void* thread_function(void* arg) {
    printf("Привет из потока! Аргумент функции: %s\n", (char*)arg);
    return NULL;
}

int main() {
    pthread_t thread_id;
    char* message = "Thread's Message";

    // создаем поток
    if(pthread_create(&thread_id, NULL, thread_function, (void*)message)) {
        fprintf(stderr, "Ошибка при создании потока\n");
        return 1;
    }

    // ожидаем завершения потока
    if(pthread_join(thread_id, NULL)) {
        fprintf(stderr, "Ошибка при ожидании потока\n");
        return 2;
    }

    printf("Поток завершил работу\n");
    return 0;
}

pthread_create starts a new thread that executes thread_functionwhile pthread_join waits for this thread to complete.

By dividing the array into parts and processing each part in a separate thread, we can significantly speed up the process. Let's sum the elements of a large array using multithreading:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

#define SIZE 1000000
#define THREADS 4

int array[SIZE];
long long sum[THREADS] = {0};

void* sum_function(void* arg) {
    int thread_part = (int)arg;
    int start = thread_part * (SIZE / THREADS);
    int end = (thread_part + 1) * (SIZE / THREADS);

    for(int i = start; i < end; i++) {
        sum[thread_part] += array[i];
    }

    return NULL;
}

int main() {
    pthread_t threads[THREADS];

    // инициализация массива
    for(int i = 0; i < SIZE; i++) {
        array[i] = i + 1;
    }

    // создание потоков для суммирования частей массива
    for(int i = 0; i < THREADS; i++) {
        pthread_create(&threads[i], NULL, sum_function, (void*)i);
    }

    // ожидание завершения всех потоков
    for(int i = 0; i < THREADS; i++) {
        pthread_join(threads[i], NULL);
    }

    // суммирование результатов
    long long total_sum = 0;
    for(int i = 0; i < THREADS; i++) {
        total_sum += sum[i];
    }

    printf("Общая сумма: %lld\n", total_sum);
    return 0;
}

The code divides the array into four parts and calculates the sum of each part in a separate thread, after which it sums the results.

Atomic operations

Atomic operations are often implemented through specifications provided as part of the C11 standard or through extensions provided by compilers such as GCC.

The C11 standard introduced explicit support for atomic operations through the module <stdatomic.h>. It provides a set of atomic types and operations for working with them.

Let's look at a basic example of how to safely increment a counter from multiple threads:

#include <stdatomic.h>
#include <stdio.h>
#include <pthread.h>

atomic_int counter = ATOMIC_VAR_INIT(0);

void* increment(void* arg) {
    for (int i = 0; i < 100000; ++i) {
        atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed);
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;

    if(pthread_create(&t1, NULL, increment, NULL)) {
        return 1;
    }
    if(pthread_create(&t2, NULL, increment, NULL)) {
        return 1;
    }

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    printf("Значение счетчика: %d\n", atomic_load(&counter));
    return 0;
}

atomic_int is used to declare a counter, and atomic_fetch_add_explicit — to atomically increase its value. memory_order_relaxed indicates that the operation can be reorganized with respect to other operations, but the increment operation itself is guaranteed to be atomic.

GCC offers extensions to atomic operations that can be used in versions of C prior to C11 or in cases where support <stdatomic.h> unavailable or unwanted.

Let's implement atomic value exchange:

#include <stdio.h>
#include <pthread.h>

int counter = 0;

void* increment(void* arg) {
    for (int i = 0; i < 100000; ++i) {
        __sync_fetch_and_add(&counter, 1);
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;

    if(pthread_create(&t1, NULL, increment, NULL)) {
        return 1;
    }
    if(pthread_create(&t2, NULL, increment, NULL)) {
        return 1;
    }

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    printf("Значение счетчика: %d\n", counter);
    return 0;
}

Function __sync_fetch_and_add from GCC extensions is used to atomically increment a counter.


In C you have maximum freedom and control over your code, but with this comes responsibility. Use this freedom wisely, being aware of the consequences of your decisions.

By following the link you can register for a free webinar of the “C Programmer” course about implementing dynamic data structures in C and Python.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *