Redis source code read the multithreading of Redis 6.0

Posted by FijiSmithy on Sat, 29 Jan 2022 10:23:52 +0100

Redis single thread refers to that the thread of event loop is single, and command execution mainly depends on a single thread. Redis uses single thread because it is fast based on memory, and multiplexing can ensure that redis can process multiple requests at the same time. The introduction of multi thread in Redis 6.0 is because some operations need to be optimized, such as deletion.

1, Redis 5.0 single thread implementation

After the client establishes a connection with the Redis server, all requests will be executed into the readQueryFromClient() method. The readQueryFromClient() method will read data from the socket and put it into the input buffer querybuf f. Then it will call the processInputBuffer() method in processInputBufferAndReplicate to parse parameters according to the RESP protocol. After parsing the parameters, the processCommand() method will be called to execute the specific command. Find the corresponding command according to the command name in processCommand() and call call() of the command to complete the specific operation. After the execution of the command, the addReply() method will be called to return the execution result.

However, it should be noted that the addReply() method only writes the returned data to the output buffer client - > buf or client - > reply, and does not perform the actual network sending operation.

Redis will call the beforeSleep() method before entering the event loop every time. The actual network data sending operation is completed in the beforeSleep() method. In beforeSleep(), handleClientsWithPendingWrites() will be called to return data to the client; in handleClientsWithPendingWrites(), writeToClient() method will be called to send the data in the output buffer client - > buf and client - > reply to the client through socket.

2, Redis 6.0 multithreading implementation

The introduction of multithreading shows that Redis has no advantages over single thread in some aspects.

Because the read/write system call of the read/write network takes up most of the CPU time during the execution of Redis, if the network read/write is made into multithreading, the performance will be greatly improved. The multithreaded part of Redis is only used to handle the reading and writing of network data and protocol parsing, and the execution of commands is still single thread.

The introduction of multi-threaded operation in Redis is also for performance considerations. For the deletion of some large key value pairs, the non blocking release of memory space through multi-threaded operation can also reduce the blocking time of Redis main thread and improve the execution efficiency.

Some students on the Internet tested the performance of the multi-threaded and single threaded versions of Redis. The comparison shows that the performance of the multi-threaded version of Redis is at least twice that of the single threaded version.

Next, let's introduce the multi threading process of Redis 6.0:

Detailed process:

  1. When Redis starts, InitServerLast() will be called to initialize the IO thread (the user sets the number of threads and allows multi-threaded reading), but the IO thread is blocked at first.
  2. Every time there is a new client request, the main thread will execute to readQueryFromClient(). In readQueryFromClient(), the main thread will add the client object to the server clients_ pending_ In the read list.
  3. After each event, that is, in afterSleep(), the Redis main thread will call the handleClientsWithPendingReadsUsingThreads() method, in which the main thread will send the server clients_ pending_ The client objects in the read list are allocated to IO in turn according to the RoundRobin algorithm_ threads_ List queue array, and wait for all IO threads to complete the data reading operation in an empty loop.
  4. While the main thread is waiting, the IO thread will start from the corresponding io_ threads_ Get the client object from the list queue, call the readQueryFromClient() method in turn to read the data and parse the parameters according to the RESP protocol.
  5. After all IO threads are executed, the main thread will call the processcommandandresettclient() method, which will call processCommand() to execute specific commands and write the execution results to the output buffer of the client object.
  6. Before each event loop, that is, in beforeSleep(), the Redis main thread will call the handleClientsWithPendingWritesUsingThreads() method. In this method, the main thread will allocate all client objects that need to return data to IO according to the RoundRobin algorithm_ threads_ List queue array and wait for all IO threads to complete the operation of writing data in an empty loop.
  7. The IO thread will start from the corresponding IO thread_ threads_ Get the client object from the list queue, call the writeToClient() method in turn, and return the data in the output buffer of the client object to the client through the socket.

(1) Initialization: initThreadedIO

First, InitServerLast() method will be called in main() method, and initThreadedIO() method will be called in InitServerLast() method. This method is mainly used to initialize IO threads.

/* Initialize IO thread */
void initThreadedIO(void) {
    // Set the flag bit, 0 indicates that the IO thread has not been activated, 1: activated
    io_threads_active = 0;
 
    // If the number of IO threads is set to 1, no extra threads will be started and only the main thread will be used
    if (server.io_threads_num == 1) return;
 
    // If the maximum number of threads exceeds 128, an error is reported
    if (server.io_threads_num > IO_THREADS_MAX_NUM) {
        serverLog(LL_WARNING,"Fatal: too many I/O threads configured. "
                             "The maximum number is %d.", IO_THREADS_MAX_NUM);
        exit(1);
    }
 
    // Initialize each IO thread in turn
    for (int i = 0; i < server.io_threads_num; i++) {
 
        io_threads_list[i] = listCreate();
         
        // If io_threads_num=0 indicates that the user does not need to start redundant IO threads and directly uses the main thread for Io
        if (i == 0) continue;
 
        pthread_t tid;
        pthread_mutex_init(&io_threads_mutex[i],NULL);
        io_threads_pending[i] = 0;
        // The current thread (main thread) will lock all mutexes first
        pthread_mutex_lock(&io_threads_mutex[i]);
        // Generate new IO threads. Each IO thread executes the IOThreadMain() method. The method parameter is the current index
        if (pthread_create(&tid,NULL,IOThreadMain,(void*)(long)i) != 0) {
            serverLog(LL_WARNING,"Fatal: Can't initialize IO thread.");
            exit(1);
        }
        io_threads[i] = tid;
    }
}

One thing to note here is that the main thread and IO thread share the variable array io_threads_pending to communicate.

Main thread modification io_threads_pending, IO thread reads io_threads_pending, then there may be a thread safety problem.

So how does Redis avoid thread safety problems? The answer is yes_ Atomic qualifier.

io_ threads_ The pending variable is added when declared_ Atomic qualifier:

_Atomic unsigned long io_threads_pending[IO_THREADS_MAX_NUM];

_ Atomic is an atomic operation introduced in C11 standard. Be_ Atomic modified variables are considered atomic variables. The operation on atomic variables is inseparable, and the operation results are visible to other threads, and the execution order cannot be rearranged.

So, io_threads_pending is a thread safe variable.

After the initThreadedIO() method is executed, io_threads_num IO threads have been started and the IOThreadMain() method is executed:

void *IOThreadMain(void *myid) {
    // First, get the current thread in io_ Subscript in threads array, in io_threads_pending and io_threads_ The subscripts in the list are consistent
    long id = (unsigned long)myid;

    while(1) {
        // Spin for a while. If the current thread is assigned a task during the spin, you don't have to grab the mutex
        // Can improve performance
        for (int j = 0; j < 1000000; j++) {
            if (io_threads_pending[id] != 0) break;
        }

        // If there is no task allocation after the spin, the IO thread will call pthread_mutex_lock() method to grab the corresponding mutex
        // However, the main thread has locked all mutexes before generating a specific IO thread, so the IO thread will be blocked due to the failure of lock grabbing
        // The main thread can stop the thread because the allocation of tasks is configured by the main thread
        if (io_threads_pending[id] == 0) {
            pthread_mutex_lock(&io_threads_mutex[id]);
            pthread_mutex_unlock(&io_threads_mutex[id]);
            continue;
        }

        serverAssert(io_threads_pending[id] != 0);

        if (tio_debug) printf("[%ld] %d to handle\n", id, (int)listLength(io_threads_list[id]));

        // The main thread will never touch IO until the pending count drops to 0_ threads_ list
        listIter li;
        listNode *ln;
        listRewind(io_threads_list[id],&li);
        while((ln = listNext(&li))) {
            client *c = listNodeValue(ln);
            // From Io_ threads_ Get tasks from the list
            // If it is a write task, write it
            // If it is a read task, perform the read operation
            if (io_threads_op == IO_THREADS_OP_WRITE) {
                writeToClient(c,0);
            } else if (io_threads_op == IO_THREADS_OP_READ) {
                readQueryFromClient(c->conn);
            } else {
                serverPanic("io_threads_op value is unknown");
            }
        }
        // clear list 
        listEmpty(io_threads_list[id]);
        io_threads_pending[id] = 0;

        if (tio_debug) printf("[%ld] Done\n", id);
    }
}

IOThreadMain() does the following in an endless loop:

  1. Judge whether the current thread has been assigned a new task. io_ threads_ The pending array holds the number of task client objects allocated to each thread (allocated by the main thread). If IO_ threads_ Pending [ID] > 0 indicates that there are new tasks to be processed. In judging IO_ threads_ When pending [ID] (ID is the index of the current thread in the array) is greater than 0, the IO thread will spin for a while. If the main thread assigns tasks to the Current IO thread during spin, the IO thread will not rob the mutex (which can save the cost of robbing the mutex). If there is no task allocation after the spin, the IO thread will call pthread_mutex_lock() method to grab the corresponding mutex. As mentioned earlier, in the initThreadedIO() method, the main thread will call pthread before generating a specific IO thread_ mutex_ Lock () locks all mutexes. Therefore, the IO thread will be in the blocking state due to the lock grabbing failure.
  2. Perform specific read or write operations. If the IO thread is assigned to a read-write task, it will perform specific read-write operations. Each IO thread will traverse its own io_threads_list[id] task queue, which performs specific read and write operations on each client object in the queue. Variable io_threads_op identifies the operations required by the current thread: If it's IO_THREADS_OP_READ indicates a read operation, and all IO threads will call the readQueryFromClient() method to read the client's request; If it's IO_THREADS_OP_WRITE means write operation. All IO threads will call writeToClient() method to return the output buffer data of each client object to the client through socket.

Note: all IO threads can only read or write at the same time.

(2) Read request: readQueryFromClient

Every time there is a new client request, the main thread will execute to readQueryFromClient() to read the request sent by the client.

void readQueryFromClient(connection *conn) {
    client *c = connGetPrivateData(conn);
    int nread, readlen;
    size_t qblen;

    // Judge whether the data reading request needs to be put into the IO thread for execution
    if (postponeClientRead(c)) return;

    //...  Omit code

    // Main process for processing input buffer
    // There is more data in the client input buffer, which needs to be parsed to check whether there are complete commands to execute
    processInputBuffer(c);
}

In readQueryFromClient(), the postponeClientRead() method will be called to determine whether the data reading request needs to be put into the IO thread for execution:

int postponeClientRead(client *c) {
    if (io_threads_active &&
        server.io_threads_do_reads &&
        !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ)))
    {
        // Add client to the flag bit of the client object_ PENDING_ Read, this is important
        c->flags |= CLIENT_PENDING_READ;
        // Add the client object to the server clients_ pending_ In the read list
        listAddNodeHead(server.clients_pending_read,c);
        return 1;
    } else {
        return 0;
    }
}

If the IO thread is activated and the flag bit of the current client does not contain CLIENT_MASTER,CLIENT_SLAVE,CLIENT_PENDING_READ, add a client to the current client object first_ PENDING_ Read flag bit, and then add the current client object to the server clients_ pending_ Read the end of the list and return 1.

After postponeClientRead() returns 1, the readQueryFromClient() method returns and ends execution.

(3) Callback after event loop blocking: handleClientsWithPendingReadsUsingThreads

Redis will call the afterSleep() method after each event, and the handleClientsWithPendingReadsUsingThreads() method will be called in the afterSleep() method.

int handleClientsWithPendingReadsUsingThreads(void) {
    // Determine whether to use multithreading for reading
    if (!io_threads_active || !server.io_threads_do_reads) return 0;
    // The client object that needs to read data is saved in server clients_ pending_ In read
    int processed = listLength(server.clients_pending_read);
    if (processed == 0) return 0;

    if (tio_debug) printf("%d TOTAL READ pending clients\n", processed);

    listIter li;
    listNode *ln;
    listRewind(server.clients_pending_read,&li);
    int item_id = 0;
    // 1. Assign read tasks according to RoundRobin algorithm
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        int target_id = item_id % server.io_threads_num;
        listAddNodeTail(io_threads_list[target_id],c);
        item_id++;
    }

    // 2. Set the read operation flag bit and count the number of tasks of each IO thread
    io_threads_op = IO_THREADS_OP_READ;
    for (int j = 0; j < server.io_threads_num; j++) {
        int count = listLength(io_threads_list[j]);
        io_threads_pending[j] = count;
    }

    // 3. Wait for all threads to process all client data reading operations
    while(1) {
        unsigned long pending = 0; 	// pending indicates the number of client s that need to be processed by all threads
        for (int j = 0; j < server.io_threads_num; j++)
            pending += io_threads_pending[j];
        if (pending == 0) break;
    }
    if (tio_debug) printf("I/O READ All threads finshed\n");

    // Run the client list again to process the new buffer
    listRewind(server.clients_pending_read,&li);
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        c->flags &= ~CLIENT_PENDING_READ;
        if (c->flags & CLIENT_PENDING_COMMAND) {
            c->flags &= ~ CLIENT_PENDING_COMMAND;
            // 4. Execute orders
            processCommandAndResetClient(c);
        }
        // 5. If there is still data to be read, read the data
        processInputBufferAndReplicate(c);
    }
    listEmpty(server.clients_pending_read);
    return processed;
}

handleClientsWithPendingReadsUsingThreads() method mainly completes the following tasks:

1. The main thread assigns tasks to the IO thread according to the RoundRobin algorithm.

2. The main thread sets the read operation flag bit and counts the number of tasks of each IO thread.

3. The main thread empty loop waits for all IO threads to process all client data reading operations.

At this point io_threads_op = IO_THREADS_OP_READ, the IO thread will execute the readQueryFromClient() method to read data.

[when I saw this before, I had a question. In the previous readQueryFromClient() method, the client object will be added to the server.clients_pending_read list. Now that the IO thread calls the readQueryFromClient() method again, will it add the current client to the server.clients_pending_read list and form an endless loop?] The answer is No.

Take another look at the postponeClientRead() method:

int postponeClientRead(client *c) {
    if (io_threads_active &&
        server.io_threads_do_reads &&
        !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ)))
    {
        // Add client to the flag bit of the client object_ PENDING_ Read, this is important
        c->flags |= CLIENT_PENDING_READ;
        // Add the client object to the server clients_ pending_ In the read list
        listAddNodeHead(server.clients_pending_read,c);
        return 1;
    } else {
        return 0;
    }
}

Add the client object to the server in the main thread clients_ pending_ Before reading the list, the client of the corresponding client will be set first_ PENDING_ The read flag bit, so when the IO thread calls the readQueryFromClient() method, it will not be added repeatedly and will continue to execute.

In Redis5, the main thread calls readQueryFromClient() to read data, and readQueryFromClient() will call processInputBuffer() method to parse parameters. After parsing parameters, processInputBuffer() will immediately call processCommand() method to execute the command and write the execution result to the output buffer. In other words, in versions before Redis6, as long as the readqueryfromclient () method is called, specific commands will be executed. However, this cannot be done in Redis6. If this is done, the IO thread will not only read data, but also execute commands. In this way, if multiple IO threads execute commands at the same time, thread safety problems may occur. In Redis6, readQueryFromClient() finally calls processInputBuffer() to parse the request parameters:

void processInputBuffer(client *c) {
    /* Keep processing while there is something in the input buffer */
    while(c->qb_pos < sdslen(c->querybuf)) {

        //... Other codes that omit parsing parameters

        if (c->argc == 0) {
            resetClient(c);
        } else {
            // Judge whether the current client is in a multithreaded environment
            // If yes, just add a new client to the client_ PENDING_ The command flag bit does not continue to execute the command
            if (c->flags & CLIENT_PENDING_READ) {
                c->flags |= CLIENT_PENDING_COMMAND;
                break;
            }

            // Execute command
            if (processCommandAndResetClient(c) == C_ERR) {
                return;
            }
        }
    }
    // Omit code
}

It can be seen from the code that the ProcessInputBuffer () method will judge whether the current clien contains a client before calling processcommandandresettclient () to execute the command_ PENDING_ Read flag bit. If yes, it only adds a client to the current client_ PENDING_ The command flag bit then returns directly and does not continue to execute the command. [therefore, after the IO thread calls the readQueryFromClient() method to read the data, it will continue to call processInputBuffer() to complete the parameter parsing, but will not continue to execute the command. Therefore, the IO thread only reads the data.]

4. After all IO threads read data, the main thread executes specific commands.

The main thread traverses the server clients_ pending_ Read list. For each client in the list, it will judge whether the current client has a client_ PENDING_ The command flag bit, if any, will continue to call processcommandandresettclient(), and processcommandandresettclient() will call processCommand() to execute specific commands.

As analyzed in the previous step, if the IO thread finds that the client object contains a client when calling processInputBuffer()_ PENDING_ After the read flag bit, the client will continue to be added to the current client object_ PENDING_ Command flag bit. So in this step, the main thread will execute the server clients_ pending_ All clients in the read column call the processcommandandresettclient () method to execute specific commands.

5. If there is still data to be read, the main thread will continue to read the data.

(4) Callback before event loop blocking: handleClientsWithPendingWritesUsingThreads

Redis will call the beforeSleep() method before the start of each event. In the beforeSleep() method, it will call the handleClientsWithPendingWritesUsingThreads() method:

int handleClientsWithPendingWritesUsingThreads(void) {
    // 1. Judge whether there is any client object that needs to write data to the client
    int processed = listLength(server.clients_pending_write);
    if (processed == 0) return 0; 

    // 2. Judge whether it is really necessary to use multi IO threads for data reading and writing (if there are only a few client s, multi threads are not required)
    if (stopThreadedIOIfNeeded()) {
        return handleClientsWithPendingWrites();
    }

    // 3. If the IO thread is not activated, start the IO thread
    if (!io_threads_active) startThreadedIO();

    if (tio_debug) printf("%d TOTAL WRITE pending clients\n", processed);

    listIter li;
    listNode *ln;
    listRewind(server.clients_pending_write,&li);
    int item_id = 0;
    // 4. Assign the client object that needs to return data to the IO thread according to the RoundRobin algorithm
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        c->flags &= ~CLIENT_PENDING_WRITE;
        int target_id = item_id % server.io_threads_num;
        listAddNodeTail(io_threads_list[target_id],c);
        item_id++;
    }

    // 5. Set the flag bit to write operation and count the number of client s to be processed by each io thread
    io_threads_op = IO_THREADS_OP_WRITE;
    for (int j = 0; j < server.io_threads_num; j++) {
        int count = listLength(io_threads_list[j]);
        io_threads_pending[j] = count;
    }

    // 6. Empty loop, listen and wait for all IO threads to complete IO reading and writing
    while(1) {
        unsigned long pending = 0;
        for (int j = 0; j < server.io_threads_num; j++)
            pending += io_threads_pending[j];
        if (pending == 0) break;
    }
    if (tio_debug) printf("I/O WRITE All threads finshed\n");

    // 7. If there is still data that has not been written, continue processing
    listRewind(server.clients_pending_write,&li);
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);

        if (clientHasPendingReplies(c) &&
                connSetWriteHandler(c->conn, sendReplyToClient) == AE_ERR)
        {
            freeClientAsync(c);
        }
    }
    
    // 8. Clear the list of client objects that need to write data
    listEmpty(server.clients_pending_write);
    return processed;
}

handleClientsWithPendingWritesUsingThreads() mainly completes the following operations:

1. Judge the number of client objects that need to return data to the client.

Redis saves the client object that needs to return data in the server clients_ pending_ In the write list; If there is no client object to process, it will be returned directly.

2. Judge whether it is necessary to use multiple IO threads for data processing.

Redis will call the stopThreadedIOIfNeeded() method to determine whether multiple IO threads are needed (based on: the number of Client objects to be processed is > twice the number of IO threads)

int stopThreadedIOIfNeeded(void) {
    int pending = listLength(server.clients_pending_write);

    if (server.io_threads_num == 1) return 1;

    // Multithreading is used only when the number of client objects currently to be processed exceeds twice the number of IO threads
    if (pending < (server.io_threads_num*2)) {
        if (io_threads_active) stopThreadedIO();
        return 1;
    } else {
        return 0;
    }
}

If you do not need to use multiple IO threads, lock the corresponding mutex and set the activation flag bit io_threads_active=0, and then the main thread still calls the handleClientsWithPendingWrites() method to complete the data return operation.

3. If multiple IO threads need to be used and the IO thread has not been activated, call startThreadedIO() to activate the IO thread.

void startThreadedIO(void) {
    if (tio_debug) { printf("S"); fflush(stdout); }
    if (tio_debug) printf("--- STARTING THREADED IO ---\n");
    serverAssert(io_threads_active == 0);
    for (int j = 0; j < server.io_threads_num; j++)
        // Release all mutexes
        pthread_mutex_unlock(&io_threads_mutex[j]);
    // Set the activation flag bit to 1
    io_threads_active = 1;
}

After the main thread releases the lock, the blocked IO thread will grab the lock and continue to judge whether there are assigned tasks.

4. The main thread allocates the client that needs to return data to the client to IO according to the Round Robin algorithm_ threads_ List array. 5. Set io_threads_op is a write operation. At the same time, count the number of client objects to be processed by each IO thread, and write the corresponding io_ threads_ In the pending array.

6. The main thread empty loop waits for all IO threads to complete execution.

[it can be seen from here that when the IO thread is performing specific read-write operations, the main thread belongs to the empty loop waiting state.]

7. If there is still data that has not been written, the main thread will continue to process it. 8. The main thread empties clients_pending_write.

It can be seen from the whole process that when the main thread executes, the IO thread is basically in the state of blocking or spin empty loop, while when the IO thread performs read-write operations, the main thread is in the state of spin empty loop. Pass between two_ Atomic variables are used to communicate, so thread safety is fundamentally guaranteed.

Overall process:

Process Description:

1. The main thread is responsible for receiving the connection establishment request, obtaining the socket and putting it into the global waiting read processing queue

2. After the main thread handles the read event, it allocates these connections to these IO threads through RR(Round Robin)

3. The main thread is blocked, waiting for the IO thread to read the socket

4. The main thread executes the request command through a single thread, and the request data is read and parsed, but it does not execute

5. The main thread is blocked, waiting for the IO thread to write data back to the socket

6. Unbind and empty the waiting queue

Topics: Redis