UNIX网络编程从零起步(3): 一连接一线程(per thread)服务器端

交互式的服务器端几乎毫无实用价值. 通常情况提供网络服务的端口都会允许多个客户端“同时”发起连接, 这就涉及到并发(concurrence)这个概念. 通过创建进程或线程, 我们都可以实现并发. 创建一个新的进程, 系统会分配给子进程一个独立的虚拟地址空间, 尽管有写时拷贝(copy-on-write, COW), fork新的进程仍然会耗费可观的CPU时间. 同时, 父进程还需要处理SIGCHLD信号. 当接收到这个信号时必须调用wait系列系统调用, 否则可能会产生大量的僵尸进程, 最后导致炒作系统崩溃. 此外, 如果父进程与子进程或者子进程之间需要通讯, 就会用到进程通讯机制, 带来编程上的复杂性. 线程之间共享虚拟地址空间, 创建一个线程比创建一个进程消耗的资源少得多, 线程之间可以通过全局变量以及堆内存通讯(实际上栈上的内存也是共享, 只是一个线程无法知道另外一个线程在栈上的变量何时会出栈, 所以线程间从来不用栈内存通讯). 多线程并发编程比多进程并发编程在某种程度上来说要容易. 当然多线程编程要小心的对付的是如何用线程同步锁和防止死锁. 接下来我都会用多线程实现并发. 这些程序只要稍作修改就能用作多进程的并发.

先从最简单的per therad模型入手. 主线程循环accpet, 将cfd传递到子线程, 子线程读取连接发送来的消息并保写入到记录文件中. 需要做日志记录的程序往往都是持续长久运行的程序, 如果只是记录一条日志就关闭连接, 下一条日志要记录又重新发起连接, 这会相当浪费资源. 所以, 在我们的子线程中, 客户端关闭连接, 也就是read返回0时, 子线程才会退出.
server2.c代码如下:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include "server.h"
 
int fd;
 
void *work_thr(void *);
 
int
main(int argc, char *argv[])
{
    int lfd;
    char *logpath = (char *) malloc(PATHSIZE);
    char *servname = (char *) malloc(SERVNAMESIZE);
    pthread_t tid;
 
    if (argc == 1) {
        snprintf(logpath, PATHSIZE, "server.log");
        snprintf(servname, SERVNAMESIZE, "5000");
    } else if (argc == 2) {
        snprintf(logpath, PATHSIZE, "server.log");
        snprintf(servname, SERVNAMESIZE, "%s", argv[1]);
    } else if (argc == 3) {
        snprintf(logpath, PATHSIZE, "%s", argv[1]);
        snprintf(servname, SERVNAMESIZE, "%s", argv[2]);
    } else {
        fprintf(stderr, "Usage:\n");
        fprintf(stderr, "%s [log file path] [server port]\n", argv[0]);
        exit(EXIT_FAILURE);
    }
 
    if ((fd = open_logfile(logpath)) < 0) {
        fprintf(stderr, "open_logfd error.\n");
        exit(EXIT_FAILURE);
    }
 
    if ((lfd = tcp_listen(NULL, servname, NULL, NULL)) < 0) {
        fprintf(stderr, "tcp_listen error.\n");
        exit(EXIT_FAILURE);
    }
 
    for (;;) {
        int *cfd;
        cfd = (int *) malloc(sizeof(int));
        *cfd = accept(lfd, NULL, NULL);
        pthread_create(&tid, NULL, work_thr, cfd);
    }
    free(logpath);
    free(servname);
    return 0;
}
 
void *
work_thr(void *arg)
{
    pthread_detach(pthread_self()); 
    int cfd = *(int *)arg;
    free(arg);
    ssize_t n;
 
    char *logbuf = (char *) malloc(LOGSIZE);
 
    for (; ;) {
        memset(logbuf, '\0', LOGSIZE);
        n = readn(cfd, logbuf, LOGSIZE);
        if (n != LOGSIZE && n != 0) {
            fprintf(stderr, "readn error.\n");
            break;
        } else if (n == 0) {
            fprintf(stderr, "connection is closed.\n");
            break;
        } else { 
            write(fd, logbuf, strlen(logbuf));
        }
    }
    close(cfd);
    free(logbuf);
    return NULL;
}

我们的第一个并发服务器端在主线程中调用accept, 然后将cfd传递给子线程, 由子线程接收客户端发送的消息并写入日志文件. 第44行创建线线程时给子线程传递的cfd是预先通过malloc分配的内存地址, 而非声明一个在栈上的int型的变量讲变量地址传递给子线程. 其中的原因是如果采用后者的方式传递参数, 在第55行赋值之前如果主线程accept再次返回, arg的内容就会更新, 导致丢掉了一个连接和资源泄露. 工作线程work_thr中处理连接的代码和交互式服务器端处理处理连接的代码基本相同. 需要提醒的是, 作为一个合格的C程序员要时刻记得只要在任何地方向操作系统申请了资源, 例如堆上的内存, 打开了文件,开启了新的线程/进程, 都要在适当的地方释放掉这些资源, 尤其是在申请和释放不在同一个函数中时. 我们54行detache了工作线程(如果不detache, 则要记得在合适的线程中join), 在56行free了42行分配的内存, 在74行关闭掉了cfd.

编译server2.c

gcc server2.c tcp_listen.c readn.c open_logfile.c -lpthread -Wall -O2 -o serv2

我们的服务器是并发的, 所以需要写个脚本来测试. 脚本如下:

#!/bin/bash
function send_log()
{
    fun_no=$1
    for ((i = 0; i < 10; i++))
    do
        ./cli "send $i in $fun_no call"
    done
 
}
 
for ((i = 0; i < 10; i++))
do
    send_log $i &
done

运行这个脚本我们会发现日志文件的记录序号不完全按照顺序排列, 这与操作系统的调度顺序有关.

这种per thread模型的服务器端能够满足中等规模的并发, 同时处理1k左右的连接不会有太大压力. 在我参与的这个项目中, 我使用的就是这种模型的日志记录程序.

此条目发表在计算机与网络技术分类目录,贴了, , 标签。将固定链接加入收藏夹。

发表评论

电子邮件地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据