多进程侦听同一端口
一、端口侦听
我们知道,系统中的互联网端口地址是系统级唯一的,在默认情况下,IPV4和IPV6的同一个协议的套接口也不能再同一个端口侦听,而套接口编程的五元组就是<IP,port,peerip,peerport,inet proto>,其中没有进程区分,所以一个系统的套接口对于同一个网络地址来说是唯一的。但是有时候为了实现负载平衡,可能希望有多个进程来侦听同一个套接口,从而并发执行某个任务,此时就希望多个相同的进程(相同的可执行文件)来对同一个套接口进行侦听,从而完成负载分流和平衡。
当然,多线程也是一种实现方法,但是缺点就是需要实现用户态编码,不对可执行程序透明,用户态的代码需要自己调用pthread_create来创建多个线程,这样属于一种硬编码的方式,有其资源共享的优点,但是会增加维护的复杂度。而一个程序同时执行多份的话,由于代码段共享的原因,系统同样不会有太大的内存开销,并且可以方便的由用户态决定启动多少个任务而不依赖代码实现。
二、fastcgi starter实现
通产来说,如果让同一个进程依次派生执行,那么这个多进程侦听同一个套接口是一定无法实现的,因为在bind系统调用会返回端口被占用错误,所以此时就需要由一个父进程来完成这个同一个的bind+listen动作,这时候把一个套接口已经培养到可以执行accept系统调用来获得连接请求的时候,这个fd相当于已经被培育成熟,所以此时根据需要个数派生服务进程,这样子进程就可以在照约定的文件描述符上进行accept接收外部连接请求。或者任务fastcgi派生的都是“官二代”,当这些子进程启动起来之后,它就可以直接从一个文件描述符上进行accept来接见各种连接请求,并且每个子进程都有这种接收机会。
这个流程无论从实现和原理上来讲都不是很复杂,但是比较有创意。大家经常说“文件是unix的精髓”,但是能够把它用到这种地步还真是不容易,同样的套接口,同样的文件描述符,就是可以做到多进程侦听同一个端口的实现。这一点和busybox的可执行文件“多路复用”一样,是一种化腐朽为神奇,或者至少是“化平凡为神奇”的实现方法。而两者也的确是依靠这两个比较有创意的思路,实现了两种非常有用的机制,busybox在嵌入式中几乎是根文件系统的基础,而fastcgi则是网络服务器中的快速响应流行模型。
httpd-2.4.2\support\fcgistarter.c中相关代码:
rv = apr_sockaddr_info_get(&skaddr, interface, APR_UNSPEC, port, 0, pool);
if (rv) {
exit_error(rv, "apr_sockaddr_info_get");
}
rv = apr_socket_create(&skt, skaddr->family, SOCK_STREAM, APR_PROTO_TCP, pool);
if (rv) {
exit_error(rv, "apr_socket_create");
}
rv = apr_socket_bind(skt, skaddr);
if (rv) {
exit_error(rv, "apr_socket_bind");
}
rv = apr_socket_listen(skt, 1024);
if (rv) {
exit_error(rv, "apr_socket_listen");
}
while (--num_to_start >= 0) {完成套接口侦听之后循环创建子进程。
rv = apr_proc_fork(&proc, pool);
…… apr_os_file_t oft = 0;注意这个文件描述符,被fcgi派生的子进程就是通过在这个文件描述符上直接执行accept系统调用来完成服务请求的,这个文件描述符在fastcgi.h中定义为#define FCGI_LISTENSOCK_FILENO 0,数值同样为零。
apr_os_sock_t oskt;
}
三、多进程竞争连接请求
内核实现部分其实并不重要,也没什么好说的,只是比较好奇,就大致看一下相关实现。
1、等待队列头创建
最原始的等待队列在sock_alloc--->>>
static struct inode *sock_alloc_inode(struct super_block *sb)
{
init_waitqueue_head(&ei->socket.wait);
}
中实现,这里其实没有什么初始化,就是初始化了一个自旋锁,并且初始化为可获取状态,它并没有初始化方法成员。
然后在__sock_create--->>inet_create--->>>sock_init_data
sk->sk_sleep = &sock->wait;
这里将sk结构中的等待队列头指向socket中的wait成员,而这个sk_sleep将会是accept的等待队列头地址。
2、accept阻塞
sys_accept---->>>inet_accept--->>inet_csk_accept---->>>inet_csk_wait_for_connect--->>prepare_to_wait_exclusive(sk->sk_sleep, &wait,TASK_INTERRUPTIBLE)
wait->flags |= WQ_FLAG_EXCLUSIVE;
spin_lock_irqsave(&q->lock, flags);
if (list_empty(&wait->task_list))
__add_wait_queue_tail(q, wait);
/*
在加入等待队列之后,通过timeo = schedule_timeout(timeo);让出调度权。
这里比较特殊的是这里的唤醒是互斥的,也就是那个 WQ_FLAG_EXCLUSIVE标志,这个标志会在唤醒函数中使用,当遇到这个标志并且唤醒互斥进程个数为1(默认情况)时只唤醒一个进程,其中的prepare_to_wait_exclusiv的wait是通过下面宏创建的
DEFINE_WAIT(wait);
3、连接到来时唤醒
tcp_v4_do_rcv--->>>tcp_child_process
/* Wakeup parent, send SIGIO */
if (state == TCP_SYN_RECV && child->sk_state != state)
parent->sk_data_ready(parent, 0);
inet_create--->>>sock_init_data
sk->sk_state_change = sock_def_wakeup;
sk->sk_data_ready = sock_def_readable;
sk->sk_write_space = sock_def_write_space;
sk->sk_error_report = sock_def_error_report;
sk->sk_destruct = sock_def_destruct;
也就是执行的sk_data_ready即为sock_def_readable函数,在该函数中,其执行操作为
static void sock_def_readable(struct sock *sk, int len)
{
read_lock(&sk->sk_callback_lock);
if (sk->sk_sleep && waitqueue_active(sk->sk_sleep))
wake_up_interruptible(sk->sk_sleep);
sk_wake_async(sk,1,POLL_IN);
read_unlock(&sk->sk_callback_lock);
}
#define wake_up_interruptible(x) __wake_up(x, TASK_INTERRUPTIBLE, 1, NULL)
可以看到,通过sk->sk_sleep唤醒了正在accept的接收套接口,并且其中__wake_up的唤醒互斥任务个数为1,所以只会唤醒一个进程,这次连接的到来对其它任务透明。