一、进程与线程

进程

  • 进程(process)”是操作里最重要的两个概念之一(另一个是文件),粗略地讲,一个进程是“内存中正在运行的程序”
  • 每个进程有自己独立的地址空间(address space),“在同一个进程”还是“不在同一个进程”是系统功能划分的重要决策点。《Erlang程序设计》[ERL]把“进程”比喻为“人”,我觉得十分精当,为我们提供了一个思考的框架
  • 每个人有自己的记忆(memory),人与人通过谈话(消息传递) 来交流,谈话既可以是面谈(同一台服务器),也可以在电话里谈(不 同的服务器,有网络通信)。面谈和电话谈的区别在于,面谈可以立即知道对方是否死了(crash, SIGCHLD),而电话谈只能通过周期性的心跳来判断对方是否还活着。
  • 有了这些比喻,设计分布式系统时可以采取“角色扮演”,团队里的几个人各自扮演一个进程,人的角色由进程的代码决定(管登录的、管 消息分发的、管买卖的等等)。每个人有自己的记忆,但不知道别人的记忆,要想知道别人的看法,只能通过交谈(暂不考虑共享内存这种 IPC)。然后就可以思考:
    • 容错:万一有人突然死了
    • 扩容:新人中途加进来
    • 负载均衡:把甲的活儿挪给乙做
    • 退休:甲要修复bug,先别派新任务,等他做完手上的事情就把他重启等等各种场景,十分便利

线程

  • “线程”这个概念大概是在1993年以后才慢慢流行起来的,距今不到20年,比不得有40年光辉历史的Unix操作系统
  • 线程的出现给Unix添了不少乱:
    • 很多C库函数(strtok()、ctime())不是线程安全的(后面还会介绍),需要重新定义
    • signal的语意也大为复杂化
  • 据我所知,最早支持多线程编程的(民用)操作系统是Solaris 2.2和Windows NT 3.1,它们均发布于 1993年。随后在1995年,POSIX threads标准确立。
  • 线程的特点是共享地址空间,从而可以高效地共享数据。一台机器 上的多个进程能高效地共享代码段(操作系统可以映射为同样的物理内 存),但不能共享数据。如果多个进程大量共享内存,等于是把多进程 程序当成多线程来写,掩耳盗铃
  • “多线程”的价值,我认为是为了更好地发挥多核处理器的效能。在单核时代,多线程没有多大价值。Alan Cox说过:“A computer is a state machine. Threads are for people who can't program state machines.”(计算机是一台状态机。线程是给那些不能编写状态机程序 的人准备的。)如果只有一块CPU、一个执行单元,那么确实如Alan Cox所说,按状态机的思路去写程序是最高效的,这正好也是下面要展示的编程模型
二、单线程服务器的常用编程模型
  • 《UNXI网络编程》对此有很好的总结(第6章的IO模型、第30章的客户端/服务器设计范式),这里不再赘述
  • 在高性能的网络程序中, 使用最为广泛的为“non-blocking IO+IO multiplexing”这种模型,即Reactor模式,我知道的有:
    • lighttpd,单线程服务器。(Nginx与之类似,每个工作进程有一个event loop)
    • libevent,libev
    • ACE,Poco C++ libraries
    • Java NIO,包括Apache Mina和Netty
    • POE(Perl)
    • Twisted(Python)
  • 相反,Boost.Asio和Windows I/O Completion Ports实现了Proactor模式,应用面似乎要窄一些。此外,ACE也实现了Proactor模式
  •  在“non-blocking IO+IO multiplexing”这种模型中,程序的基本结构是一个事件循环(event loop),以事件驱和事件回调的方式实现业务逻辑:

muduo网络库:07---多线程服务器之(单线程服务器、多线程服务器的常用编程模型)_线程池

  • 这里select/poll有伸缩性方面的不足,Linux下可替换为epoll,其他操作系统也有对应的高性能替代品(参阅:http://kegel.com/c10k.html
  • Reactor模型的优点很明显:
    • 编程不难,效率也不错
    • 不仅可以用于读写socket,连接的建立(connect/accept)甚至DNS解析(gethostbyname是阻塞的,对陌生域名解析的耗时可长达数秒)都可以用非阻塞方式进行,以提高并发度和吞吐量(throughput),对于IO密集的应用是个不错的选择
    • lighttpd就是这样,它内部的fdevent结构十分精妙,值得学习。
  • 基于事件驱动的编程模型也有其本质的缺点:
    • 它要求事件回调函数必须是非阻塞的
    • 对于涉及网络IO的请求响应式协议,它容易割裂业务逻辑,使其散布于多个回调函数之中,相对不容易理解和维护
    • 现代的语言有一些应对方法(例如coroutine)
三、多线程服务器的常用编程模型
  • 这方面能找到的文献不多,大概有这么几种:
    • 1.每个请求创建一个线程,使用阻塞式IO操作。在Java 1.4引入 NIO之前,这是Java网络编程的推荐做法。可惜伸缩性不佳。
    • 2.使用线程池,同样使用阻塞式IO操作。与第1种相比,这是提高性能的措施
    • 3.使用non-blocking IO+IO multiplexing。即Java NIO的方式
    • 4.Leader/Follower等高级模式
  • 这些内容在后面的“muduo多线程模型中”文章中会详细介绍
  • 在默认情况下一般使用第3种,即non-blocking IO+one loop per thread模式来编写多线程C++网络服务程序
  • 可供的参考文献有:http://www.cs.uwaterloo.ca/~brecht/pubs.htmlhttp://hal.inria.fr/docs/00/67/44/75/PDF/paper.pdf

①one loop per thread

muduo网络库:07---多线程服务器之(单线程服务器、多线程服务器的常用编程模型)_多线程_02

  • 这种方式的好处是:
    • 线程数目基本固定,可以在程序启动的时候设置,不会频繁创建与销毁
    • 可以很方便地在线程间调配负载
    • IO事件发生的线程是固定的,同一个TCP连接不必考虑事件并发
  • Eventloop代表了线程的主循环,需要让哪个线程干活,就把timer或IO channel(如TCP连接)注册到哪个线程的loop里即可:
    • 实时性有要求的connection可以单独用一个线程
    • 数据量大的connection可以独占 一个线程,并把数据处理任务分摊到另几个计算线程中(用线程池)
    • 其他次要的辅助性connections可以共享一个线程
  • 对于non-trivial的服务端程序,一般会采用non-blocking IO+IO multiplexing每个connection/acceptor都会注册到某个event loop上,程序里有多个event loop,每个线程至多有一个event loop
  • 多线程程序对event loop提出了更高的要求,那就是“线程安全”:
    • 要允许一个线程往别的线程的loop里塞东西(比如说主IO线程收到一个新建连接,分配给某个子IO线程处理),这个loop必须得是线程安全的
    • 如何实现一个优质的多线程Reactor?我们会在后面的“muduo设计与实现”专栏中介绍

②线程池

  • 不过,对于没有IO而光有计算任务的线程,使用event loop有点浪费,我会用一种补充方案,即用blocking queue实现的任务队列 (TaskQueue):
typedef std::function<void()> Functor;
BlockingQueue<Functor> taskQueue;     //线程安全的阻塞队列

void workerThread()
{
    //running是个全局标志
    while (running)
    {
        Functor task = BlockingQueue.take(); //this blocks
        task(); //在产品代码中需要考虑异常处理
    }
}
  • 用这种方式实现线程池特别容易,以下是启动容量(并发数)为N的线程池:
int N = num_of_computing_threads;
for (int i = 0; i < N; ++i)
{
    create_thread(&workerThread);
}
  • 使用起来也很简单:
//假设Foo有个calc()成员函数
Foo foo;
std::function<void()> task = std::bind(&Foo::calc, &foo);
taskQueue.post(task);
  • 上面十几行代码就实现了一个简单的固定数目的线程池:
    • 功能大概相当于Java中的ThreadPoolExecutor的某种“配置”
    • 当然,在真实的项目中,这些代码都应该封装到一个class中,而不是使用全局对象
    • 另外需要注意一点:Foo对象的生命期(前面“对象的声明周期”讨论了这个问题)
  • muduo的线程池比这个略复杂,因为要提供stop()操作(可参阅:muduo/base/ThreadPool{h,cc})
  • 除了任务队列,还可以用BlockingQueue<T>实现数据的生产者消费者队列,即T是数据类型(例如std::string或google::protobuf::Message*)而非函数对象,queue的消费者(s)从中拿到数据进行处理
  • BlockingQueue<T>是多线程编程的利器,它的实现可参照Java util.concurrent里的(Array|Linked)BlockingQueue。这份Java代码可读性很高,代码的基本结构和教科书一致(1个mutex,2个condition variables),健壮性要高得多。如果不想自己实现,用现成的库更好。 muduo里有一个基本的实现,包括无界的BlockingQueue和有界的BoundedBlockingQueue两个class。有兴趣的读者还可以试试Intel Threading Building Blocks里的concurrent_queue,性能估计会更好。
三、多线程编程的推荐模式
  • 总结起来,推荐的C++多线程服务端编程模式为:one (event) loop per thread+thread pool:
    • event loop(也叫IO loop)用作IO multiplexing,配合non-blocking IO和定时器
    • thread pool用来做计算,具体可以是任务队列或生产者消费者队列
  • 以这种方式写服务器程序,需要一个优质的基于Reactor模式的网络库来支撑,muduo正是这样的网络库:
    • 程序里具体用几个loop、线程池的大小等参数需要根据应用来设定,基本的原则是“阻抗匹配”,使得CPU和IO都能高效地运作,具体的 例子见此处
    • 此外,程序里或许还有个别执行特殊任务的线程,比如logging,这对应用程序来说基本是不可见的,但是在分配资源(CPU和IO)的时候要算进去,以免高估了系统的容量
四、附加
  • 本专题未完结,参阅下一篇文章(进程间通信只用TCP)javascript:void(0)