一起來寫web server 08 -- 多線程+非阻塞IO+epoll


到了多線程,一些東西就變得耐人尋味了.

這個版本是在前面單線程epoll的基礎上引入了線程池,當然不是前面玩具一樣的線程池,而是一個通用的組件,生產者消費者隊列.

生產者消費者隊列

生產者消費者問題是操作系統中一個很經典的同步互斥問題,已經有了很不錯的解決方案,將它的解決方案拓展一下,就可以用於我們的實踐啦.

我自己寫了一個生產者消費者的隊列,然後發現muduo中已經內置了這種模型,而且使用起來比我寫的更加順手,所以我就引用它的實現,我這裏稍微來講解一下它的實現,然後我會順帶講解一下我的思路.

muduo庫的生產者消費者模型

這是ThreadPool類的一個聲明:

class ThreadPool : noncopyable
{
public:
    typedef boost::function<void()> Task; /* 需要執行的任務 */
private:
    bool isFull();
    Task take();
    size_t queueSize();
    int threadNum_; /* 線程的數目 */
    int maxQueueSize_;
    std::list<Task> queue_; /* 工作隊列 */
    MutexLock mutex_;
    Condition notEmpty_;
    Condition notFull_;
};

這裏的boost::function其實在cpp 11標準中已經加入了,你如果沒有安裝boost庫的話,可以緩存std的版本,效果是一樣的.因爲boost本來就是cppstd的一個備選庫.

爲什麼要使用boost::function不用我多說,你可以查看這裏:http://blog.csdn.net/solstice/article/details/3066268

我們來看一下代碼的實現,首先是構造函數:

ThreadPool::ThreadPool(int threadNum, int maxQueueSize)
    : threadNum_(threadNum)
    , maxQueueSize_(maxQueueSize)
    , mutex_()
    , notEmpty_(mutex_)
    , notFull_(mutex_)

{
    assert(threadNum >= 1 && maxQueueSize >= 1);
    /* 接下來要構建threadNum個線程 */
    pthread_t tid_t;
    for (int i = 0; i < threadNum; i++) {
        Pthread_create(&tid_t, NULL, startThread, this);
    }
}

這裏ThreadPool有兩個條件變量,一個是notEmpty_,一個是notFull_,構造函數接受兩個參數,一個是線程的數目,一個是最大的隊列的大小.

接下來是所有的線程都運行的函數startThread:

void* ThreadPool::startThread(void* obj)
{ /* 工作者線程 */
    Pthread_detach(Pthread_self());
    ThreadPool* pool = static_cast<ThreadPool*>(obj);
    pool->run();
    return pool;
}

它們都開始調用run函數:

void ThreadPool::run()
{
    for ( ; ; ) { /* 一直運行下去 */
        Task task(take());
        if (task) {
            //mylog("task run!");
            task();
        }
        //mylog("task over!");
    }
}

run函數非常簡單,就是不斷從隊列中取出任務,然後運行任務,沒有任務的話,會阻塞在那裏.

我們來看take函數:

ThreadPool::Task ThreadPool::take()
{
    MutexLockGuard lock(mutex_); /* 加鎖 */
    while (queue_.empty()) { /* 如果隊列爲空 */
        notEmpty_.wait(); /* 等待 */
    }
    Task task;
    if (!queue_.empty()) {
        task = queue_.front();
        queue_.pop_front();
        if (maxQueueSize_ > 0) { /* 通知生產者隊列有空位置了 */
            notFull_.notify();
        }
    }
    //mylog("threadpool take 1 task!");
    return task;
}

對於生產者而言,有一個非常重要的函數,那就是append:

bool ThreadPool::append(Task&& task)
{ /* 使用了右值引用 */
    {
        MutexLockGuard lock(mutex_); /* 首先加鎖 */
        while (isFull()) { /* 如果隊列已滿 */
            notFull_.wait(); /* 等待queue有空閒位置 */
        }
        assert(!isFull());
        queue_.push_back(std::move(task)); /* 直接用move語義,提高了效率 */
        //mylog("put task onto queue!");
    }
    notEmpty_.notify(); /* 通知消費者有任務可做了 */
}

生產者消費者隊列的代碼就是這麼簡單,但是muduo庫寫的確實很漂亮.

我的思路

其實代碼基本上和前面的類似,不同的是,我壓根就沒有考慮過使用boost::funcitonboost::bind這對神器,因爲我之前也壓根就沒有這樣編過碼.

如果不用boost::funcitonboost::bind這兩樣東西,我們要實現類似的代碼的話,可能的一個解決方案是使用模版(template).

隊列裏面放的是T類型,然後消費者取出一個T類型,調用T類型的一個run或者別的什麼不帶參數的方法.這樣以來,對T類型就有了限制,要求T類型必須實現run之類的方法.

而且代碼變得不太容易讀.加了模版的玩意總是不容易讀,不是嗎?所以要積極使用cpp的新特性.

主程序變成了生產者

這一次的代碼變得簡潔多了,

int main(int argc, char *argv[])
{
    int listenfd = Open_listenfd(8080); /* 8080號端口監聽 */
    epoll_event events[MAXEVENTNUM];
    sockaddr clnaddr;
    socklen_t clnlen = sizeof(clnaddr);

    block_sigpipe(); /* 首先要將SIGPIPE消息阻塞掉 */

    int epollfd = Epoll_create(1024); /* 10基本上沒有什麼用處 */
    addfd(epollfd, listenfd, false); /* epollfd要監聽listenfd上的可讀事件 */
    ThreadPool pools(10, 30000); /* 10個線程,300個任務 */
    HttpHandle::setEpollfd(epollfd);
    HttpHandle handle[2000];

    for ( ; ;) {
        int eventnum = Epoll_wait(epollfd, events, MAXEVENTNUM, -1);
        for (int i = 0; i < eventnum; ++i) {
            int sockfd = events[i].data.fd;
            if (sockfd == listenfd) { /* 有連接到來 */
                //mylog("connection comes!");
                for ( ; ; ) {
                    int connfd = accept(listenfd, &clnaddr, &clnlen);
                    if (connfd == -1) {
                        if ((errno == EAGAIN) || (errno == EWOULDBLOCK)) { /* 將連接已經建立完了 */
                            break;
                        }
                        unix_error("accept error");
                    }
                    handle[connfd].init(connfd); /* 初始化 */
                    addfd(epollfd, connfd, false); /* 加入監聽 */
                }
            }
            else { /* 有數據可讀或者可寫 */
                pools.append(boost::bind(&HttpHandle::process, &handle[sockfd]));
            }
        }
    }
    return 0;
}

注意最後的一句boost::bind(&HttpHandle::process, &handle[sockfd]),直接將對象往函數上一綁定,就往隊列裏面扔.非常爽.

這一次,我們終於將SIGPIPE消息給忽略掉了,主要是調用下面這個函數:

void block_sigpipe()
{
    sigset_t signal_mask;
    sigemptyset(&signal_mask);
    sigaddset(&signal_mask, SIGPIPE);
    int rc = pthread_sigmask(SIG_BLOCK, &signal_mask, NULL);
    if (rc != 0) {
        printf("block sigpipe error\n");
    }
}

shared_ptr並不是線程安全的

正如文章開頭所講的,多線程一來,很多事情就變得莫名奇妙了,比如說shared_ptr,因爲這個玩意的線程不安全性,我調了半天bug,才發現原來是Cache的查找函數出了問題,下面是修改過後的線程安全版本的函數:

/* 線程安全版本的getFileAddr */
    void getFileAddr(std::string fileName, int fileSize, boost::shared_ptr<FileInfo>& ptr) {
        /*-
        * shared_ptr並不是線程安全的,對其的讀寫都需要加鎖.
        */
        MutexLockGuard lock(mutex_);
        if (cache_.end() != cache_.find(fileName)) { /* 如果在cache中找到了 */
            ptr = cache_[fileName];
            return;
        }
        if (cache_.size() >= MAX_CACHE_SIZE) { /* 文件數目過多,需要刪除一個元素 */
            cache_.erase(cache_.begin()); /* 直接移除掉最前一個元素 */
        }
        boost::shared_ptr<FileInfo> fileInfo(new FileInfo(fileName, fileSize));
        cache_[fileName] = fileInfo;
        ptr = std::move(fileInfo); /* 直接使用move語義 */
    }

至於爲什麼不安全,可以查看這裏,寫的再好不過了:http://blog.csdn.net/solstice/article/details/8547547

多線程的調試

原諒我到了這接近尾聲的時候,才提起多線程的調試,首先要說一句的是,多線程真的不太好調,因爲很難重現錯誤,但是我在這裏稍稍介紹一下我的技巧.

打印

打印算是屢試不爽的一種方法,對於我們這個簡陋的web server,我封裝了一個日誌函數mylog:

void mlog(pthread_t tid, const char *fileName, int lineNum, const char *func, const char *log_str, ...)
{
    va_list vArgList; //定義一個va_list型的變量,這個變量是指向參數的指針.
    char buf[1024];
    va_start(vArgList, log_str); //用va_start宏初始化變量,這個宏的第二個參數是第一個可變參數的前一個參數,是一個固定的參數
    vsnprintf(buf, 1024, log_str, vArgList); //注意,不要漏掉前面的_
    va_end(vArgList);  //用va_end宏結束可變參數的獲取
    printf("%lu:%s:%d:%s --> %s\n", tid, fileName, lineNum, func, buf);
}

然後定義了一個宏,方便使用這個函數:

#define mylog(formatPM, args...)\
  mlog(pthread_self(), __FILE__, __LINE__, __FUNCTION__,  (formatPM) , ##args)

需要日誌的時候,可以像printf函數一樣使用:

mylog("My simple web server! %d, %s\n", 1, "hello, workd!");

這個宏展開後會調用mlog函數,打印出行,文件名,函數名等信息,對付我們這個小玩意足夠了.

用VS來調試

VS其實也內置了線程的調試,你可以結合Visual Gdb一起來調試linux下的代碼.一兩個線程問題倒是不大,不過線程多了的話,這個玩意就不好調了,要我說,最好的方法還是分析日誌.

總結

這個版本已經算是比較強勁的一個版本了,修復了前面的一些bug,但是引入了新的bug,這個bug我也是折騰了很久才弄出來.

一般在單線程下不可能出現這樣的bug,只有在多線程的條件下,這樣的代碼才變成了bug,正如前面見到的,每個HttpHandle處理一個連接,試想這樣一種情形:客戶端不知道因爲什麼原因,第一次發送了這樣的數據:

GET /

隔了很短時間纔會發送餘下的數據.這時,第一次發送的數據正在被另外一個線程處理,在多線程條件下,對於第二次到來的數據,這個HttpHandle會交由另外一個線程處理,也就是說,有兩個線程在不加鎖地使用同一個HttpHandle,不出問題纔怪.

解決方案是有的,那就是EPOLLONESHOT參數.不過那是下一個版本的故事啦.

和之前類似的,代碼在這裏:https://github.com/lishuhuakai/Spweb

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章