多进程-> 多线程 -> 异步 -> EventDriven -> Actor模式

没有感悟的经历,似乎不是我的风格。格物致知的本质是列举和比较。经历了许多,似乎我们看透的本质还是不够本质。 

作为一个老程序猿了。对于老调重弹的如何能够更好的利用机器资源这件至始至终的老话题再说一下。谈一下,昨天,今天和明天。

写这篇文章的时候,发现了这篇网友作品,不禁莞尔一笑。很多经历似乎历历在目,不知从何说起啊。

Actor模型是解决高并发的终极解决方案_红豆和绿豆的博客-CSDN博客

似乎我想贯穿一下Web服务器 (Web Server),和应用服务器(App Server)两个主线。

多进程

当我还在深度领悟  Unix编程艺术的时候. 那个时候,卖操作系统版权还是一个非常赚钱的生意。当时,我们了解的操作系统有很多,Sun Solaris, HP-UX, IBM AIX,IBM OS2, HP OpenVMS, FreeBSD。各个大的硬件厂商都在推动自己的操作系统的操作系统。当时OpenVMS 还有再法国铁路使用近10年而没有重启的记录,在骄傲的失去市场。Sun Solaris抓住了开发者的心,得益于Sun的工程师文化,Solaris 应该是最懂开发者的操作系统。IBM牢牢占据高端服务器市场,地位无可撼动。去IOE(IBM, Oracle, EMC)的运动在 Sun, HP, BEA System的推进下,逐步唤起大家对大型机方案的反抗。Max OS, Windows, Linux都还是弟弟。 而 Linux对世界的统治完全还没有开始。 《Unix编程艺术》这本书在很大程度上是在代表着一个以类Unix系统的阵营。而这个阵营,奉行Kiss原则,执行Posix标准。在很大程度上在中高端服务器市场上称王称霸。计算机市场还在风行着科学家作软件的存粹和超脱。

多进程-> 多线程 -> 异步 -> EventDriven -> Actor模式_第1张图片

Unix IPC

IPC 是进程间通信(interprocess communication)的简称。传统上该术语描述的是运行在某个操作系统之上的不同进程间各种消息传递(message passing)的方式。还讲述多种形式的同步(synchronization),因为像共享内存区需要某种形式的同步参与。

文件系统支持的几种方式:(everything is file)

  • 磁盘文件(disk file):在磁盘持久化的文件。通过文件,进程之间可以通过读写模式共享数据。
  • 管道(pipe):一个广泛使用的 IPC 形式,既可在程序中使用,也可以在 shell 中使用。管道的问题在于它们最初只能在具有共同祖先(指父子进程关系)的进程间使用,不过该问题已随命名管道(named pipe)即 FIFO 的引入而解决。匿名管道与命名管道均为半双工,数据只能单向流动。
  • 套接字(socket):对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议根进行交互的接口。
  • 域套接字 (domain socket):socket API原本是为网络通讯设计的,但后来在socket的框架上发展出一种IPC机制,就是UNIX Domain Socket。虽然网络socket也可用于同一台主机的进程间通讯(通过loopback地址127.0.0.1),但是UNIX Domain Socket用于IPC更有效率:不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。这是因为,IPC机制本质上是可靠的通讯,而网络协议是为不可靠的通讯设计的。UNIX Domain Socket也提供面向流和面向数据包两种API接口,类似于TCP和UDP,但是面向消息的UNIX Domain Socket也是可靠的,消息既不会丢失也不会顺序错乱。

System V IPC/BSD IPC

  • 消息队列(message queue):消息的队列,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
  • 共享内存(shared memory):映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
  • 信号量(semaphore):信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。

远程过程调用(remote procedure call,简称 RPC):出现在 20 世纪 80 年代中期,它是从一个系统(客户主机)上某个程序调用另一个系统(服务器主机)上某个函数的一种方法。

进程、线程与信息共享

按照传统的 UNIX 编程模型,我们在一个系统上运行多个进程,每个进程都有各自的地址空间。UNIX 进程间的信息共享可以有多种方式。UNIX 进程间共享信息的三种方式如图:

IPC 方式


1)左边的两个进程共享存留于文件系统中某个文件的某些信息。为了访问这些信息,每个进程都得穿过内核(例如 read、write、lseek等)。当一个文件有待更新时,某种形式的同步是必要的,这样既可保护多个写入者,防止相互干扰,也可保护多个读出者,防止写入者的干扰。
2)中间的两个进程共享驻留于内核中的某些信息。管道是这种共享类型的例子,消息队列和信号量也是。当前情况下,访问共享信息的每次操作涉及对内核的一次系统调用。
3)右边的两个进程有一个双方都能访问的共享内存区。每个进程一旦设置好该共享内存区,就能不涉及内核而直接访问其中的数据。共享内存区的进程需要某种形式的同步。

注意:

  • 在这里,共享内存在进程间通讯已经暴露出了内存安全的问题。内存安全在很大程度上需要编程语言来保证。不然就是程序员来保证。
  • 本文后来讨论的多线程(两个进程共享所有内存)更需要保证内存安全性。对编程语言内存安全性要求更高。

Apache Server

多进程-> 多线程 -> 异步 -> EventDriven -> Actor模式_第2张图片

当然,比较贴近大家的事情还是Apache + PHP。虽然PHP依然是最受程序员欢迎的语言。 PHP在最开始的时候也如同 TUX一样,只有经典的单线程模式,每一个PHP程序都是独立的进程通过IPC和Apache通信。大家感兴趣的话可以扫一眼这个文档。

PHP的架构及其原理概述 - 知乎

这个模式就相当于这个例子给我们表达的那样。

Design a concurrent server for handling multiple clients using fork() - GeeksforGeeks

// Accept connection request from client in cliAddr
// socket structure
clientSocket = accept(
	sockfd, (struct sockaddr*)&cliAddr, &addr_size);

// Make a child process by fork() and check if child
// process is created successfully
if ((childpid = fork()) == 0) {
	// Send a confirmation message to the client for
	// successful connection
	send(clientSocket, "hi client", strlen("hi client"),
		0);
}

Apache Server无疑是非常成功的。Apache用过这种方式支持大量的不同编程语言来实现 CGI (Common Gateway Interface) 。这样是一开始我们支持动态网页的重要方式。

大家如果想更加深入的了解CGI的话,我们可以查看以下文档。

关于CGI和FastCGI的理解 - 天生帅才 - 博客园C

由于CGI过于沉重,逐步被替换也是正常的事情。但是为什么是Java Web Server? 关键还是内存安全问题。Java的效率不高,但是还是比启动多个进程通过IPC才能支持动态网页的方式效率还是搞很多。以后就出现了JSP等新的东西。

System V IPC

System V IPC指的是AT&T在System V.2发行版中引入的三种进程间通信工具。如果不是白发苍苍的BEA System退休架构师讲这段历史,我们很难了解TUX和UNIX一起成长的历史。需要详细了解 的人可以看手册。而且这里的文档也给了很详细的介绍。感兴趣细节的可以参看。

第四十讲 system-v_꧁༺夜༒雨༻꧂的博客-CSDN博客_system v第四十章 system-v文章目录第四十章 system-v一、system-v 消息队列1、system-v ipc特点2、消息队列用法3、常用函数4、示例5、效果二、system-v 信号量1、介绍2、信号量的用法3、常用函数4、示例5、现象三、system-v 共享内存1、介绍2、共享内存的用法3、常用函数4、示例5、现象一、system-v 消息队列1、system-v ipc特点独立于进程没有文件名和描述符IPC 对象具有 key 和 ID2、消息队列用法定义一个唯一的keyhttps://blog.csdn.net/qq_34355238/article/details/121780861?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522165737301816782390560382%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&request_id=165737301816782390560382&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduend~default-1-121780861-null-null.142%5Ev32%5Epc_rank_34,185%5Ev2%5Econtrol&utm_term=System-V&spm=1018.2226.3001.4187

简单的讲就是下面的操作函数。

分类 创建函数 控制函数 独立函数
消息队列 msgget msgctl msgsnd,msgrcv
信号量 semget semctl semop
共享内存 shmget shmctl shmat,shmdt

Tuxedo (C/C++的应用服务器)

多进程-> 多线程 -> 异步 -> EventDriven -> Actor模式_第3张图片

我2005年再BEA System维护Tuxedo系统。那个时候,Tuxedo 系统已经成熟的推出了7.X, 8.X 两个支持多线程的版本。也正在开发9.0的 release candidate. 但是 Tuxedo 6.x (当时是 Tux 6.5)版本,还是只支持多进程。每个进程都是单线程。这种模式非常经典的保证了系统的可靠性。由于Tuxedo是 TUX (Trancaction on Unix) 的另一种说法。是也Unix一起长大的。再AT&T Unix 在增加System V IPC设计的时候就考虑到多进程构造的分布式软件系统相互之间协调的问题,用以支持TUX的IPC场景。我对对进程的深入理解也就是从这里开始。

而在Tuxedo里面,消息队列是进程间实时数据通信的方法。信号量是进程间同步的机制。而共享内存是进程间共享数据的重要方式。

这里,我们可以相信操作系统帮你做好进程间的隔离,并且在引用进程都退出的时候,自动回收内核中的对象资源。唯一值得我们担心的是共享内存,在多个进程在同时写入的时候,我们还是要使用信号量的机制来保证内存中数据的保护。这个共享内存中数据保护的问题,在多线程过程中就已经存在,而在后续的多线程数据中更加突出。毕竟线程之间并没有进程之间的独立内存空间,每个线程都有破坏整个进程的潜力。

TUX是C/C++的应用服务器,也就是使用C/C++程序构建的函数和全局的共享内存在同一个进程执行。显然应用程序代码的测试力度会大大低于系统软件。C/C++的内存操作安全性有广为诟病。如果应用程序代码出现越界写,并且恰好写坏了共享内存,整个服务器甚至整个集群的状态都会有此破坏。这对于TUX这种严肃的企业级中间件来说是不能允许的。

TUX在解决共享内存数据安全的办法之一就是使用独立的进程维护共享状态。其他进程在需要读或者写的时候才真正Attach到共享内存上。这样,最小化共享内存信息被未被严格测试的程序写坏的机会。而这种用的时候才Attach的方法,会使这种IPC机制本身的性能优势变得荡然无存。所以这种方式在TUX里称为保护模式,只有在特殊情况下(debug, 测试周期短的紧急上线)的情况下才会使用。

共享内存和内存安全性的思辨

而这个共享内存安全性的问题在TUX后续的多线程版本愈演愈烈。最终在一个内存不安全的编程语言下产生的应用服务器体系很难在生产力上和后续的J2EE抗衡。一代经典TUX逐渐消失在广大程序员的视野之外。而BEA的业务也开始转向JAVA体系直至后来被Oracle收购。

我也在思考,如果是内存安全如Rust这种编译执行的编程语言,是否还能支撑起应用服务器呢?这个是需要思辨的问题。而Artix这种Actor模式的应用服务器也许是正解。各位不要慌,我们会在逐步比较的过程中得出一些判断。

多线程

多进程有自己的安全性,而且在很大程度上也可以完全利用机器的所有资源。只是进程和进程之间毕竟有很多的隔离限制,IPC的调用必须精心设计。这就给程序开发带来了额外的开销。

共享内存实际上是给进程的内存空间隔离开了一个天窗。儿多线程基本上就是把整个屋子都共享了。从此共享内存从后门变成了套路。而内存安全问题就变成了一个大大的问题。

process vs thread

Multi-Thread Server

Handling multiple clients on server with multithreading using Socket Programming in C/C++ - GeeksforGeeks

大家可以看出来,在multi-thread的程序里确实减少了IPC, Fork的很多比较重的调用。所以,我们可以很好的利用多核CPU的能力。

在TUX做企业应用时,在跨进程信号量处理的时候,就实现了基于共享内存机制的用户级别信号量。而且在Windows Server基础上实现了System V IPC.

当前,有一些文章也对类似机制作出了详述。

linux 内核信号量 用户态信号量 详解_DemonHunter211的博客-CSDN博客

由于当时操作系统之争还在继续。各个操作系统对多线程API的抽象还是有差异的。着就促成了pthread标准的形成。我们可以通过 Linux 的实现来了解 pthread.

pthread详解_提出问题 解决问题的博客-CSDN博客_pthread()

在内核级别实现的线程一般来说是使用轻进程(Light Weight Process,简称LWP)的方式来实现的。实际上,就是内核帮助调度线程的计算资源,并且调度线程的时间片。这种实现多线程的方式是最初比较普遍的方式。但是最初的时候,这种方式的效率有问题。实际上启动轻量级进程的开销和启动进程的开销比没有优化太多。

线程同步机制

线程池

线程作为一种资源,还是需要管理的。

用户态线程

由于需要更多优化线程的速度。Java, Python, ErLang 虚拟机都有绿线程的机制。我们也可以参考go语言的机制更加深入的了解。

被时间“埋没”的高并发王者:Erlang - 知乎

多进程-> 多线程 -> 异步 -> EventDriven -> Actor模式_第4张图片

Golang协程详解和应用 - 知乎

Go语言在这方面很重要的改进就是在语法级别上支持用户态线程(协程)。这在主流程序语言里是一大进步。

更加详细的极致也可以查看下面的blog

Scheduling In Go : Part I - OS Scheduler

Garbage Collection In Go : Part I - Semantics

对于绿线程,我们可以按照下面的代码进行更加深入的了解。

Introduction - Green Threads Explained in 200 Lines of Rust

随着Java内存安全性和绿线程的使用。Tomcat等这种WebServer开是崛起,并且像JBoss, WebLogic等基于J2EE的AppServer也开始大行其道。

而真正的挑战还是来了。在大量并发的情况下,我们还是要认为goroutine这样的轻量级线程也是负担。也是需要妥善管理的。所以我们还是需要通过池化的线程和事件化的处理来增强系统的处理效率。

​​​​​​用 Golang 实现百万级 Websocket 服务 | Go 技术论坛

编程语言之争

尽管C/C++在编译语言在性能上的

Tomcat, Jetty, Golden Fish 

[TBD]

内嵌式开发方式

Web Assembly, JS Engine, eBPF, Lua...

Async

如果程序调用某个方法,等待其执行全部处理后才能继续执行,我们称其为同步的。相反,在处理完成之前就返回调用方法则是异步的。我们在编程语言的流程中添加了异步控制的部分,这部分的编程可以称之为异步编程

由于大多数的流程都是顺序执行的,所以在顺序的设计流程是我们比较容易理解的方式。而且在软件设计方法上,比较容易支持基于顺序执行的方法。所以,一般来讲,对于复杂逻辑的设计。不管有没有并发,我们都会通过线程来进行建模。需要操作线程的共享状态时,就需要排队执行。线程的同步在就是在保护运行是由于无法状态一致性来进行操作顺序化的过程。

而异步编程更多的是(Event Driven)即事件驱动的模型。事件(时钟,IO事件,CPU中断,等等)可能是我们不可知的顺序到达。特别是我们同时处理多个输入输出,多个文件IO的时候。这些事件需要得到及时的响应。而线程资源在这个时候只是一种 CPU 资源的代名词。如果被分配到线程,并且得到了调度,这时候我们的事件就得到了及时的响应。处理了事件,在下一个事件尚未到达时,CPU大可不必去做什么事情。在这种情况下,我们也大可不必使用大量的线程池来处理事件。因为只要有CPU数量相同的线程,我们就足以利用几乎所有的CPU资源。

操作系统异步编程的支持

操作系统需要能够让操作系统的事件反应到程序中来。我们看看一个进程能够获得哪些事件:

Signal

IO/Event

POLL/Select/EPOLL

Socket Programming in C/C++: Handling multiple clients on server without multi threading - GeeksforGeeks

select可以支持的句柄个数是1023个。对于epoll的一些介绍,我们可以看下面的文章。

深入理解 Epoll - 知乎

NIO

Non-blocking I/O with pipes in C - GeeksforGeeks

关于Blocking IO, Non-Blocking IO 和 Asynchronous I/O的理解 - 做个不善的人 - 博客园概括来说,一个IO操作可以分为两个部分:发出请求、结果完成。如果从发出请求到结果返回,一直Block,那就是Blocking IO;如果发出请求就可以返回(结果完成不考虑),就是non-blockinhttps://www.cnblogs.com/whyandinside/archive/2012/03/04/2379234.html

non-blocking I/O Multiplexing + poll/epoll 的正确使用 - 腾讯云开发者社区-腾讯云在前面的文章中曾经粗略讲过poll,那时是用阻塞IO实现,在发送和接收数据量都较小情况下和网络状况良好的情况下是基本没有问题的,read 不会只接收部分数据,...https://cloud.tencent.com/developer/article/1345067

Nginx

[TBD]

Istio

[TBD]

Rust Tokio 

语法上对Async支持对大的还是Rust。对异步编程,Rust提供了一系列的关键字。

A practical guide to async in Rust - LogRocket BlogIf you're writing an asynchronous program in Rust or using an async library for the first time, this tutorial can help you get started.https://blog.logrocket.com/a-practical-guide-to-async-in-rust/

如果想深入理解Tokio的底层机制,我们可以看这个手册。非常的透彻。 

Async in depth | Tokio - An asynchronous Rust runtimeTokio is a runtime for writing reliable asynchronous applications with Rust. It provides async I/O, networking, scheduling, timers, and more.https://tokio.rs/tokio/tutorial/async#

Async Transaction

对于异步编程模型可能带来的一些变化,虽然在一定程度上减少了线程同步的影响。但是在一些操作上,由于多线程任务分派的存在,还是会有一些影响。我们以数据库事务为例来说明这一点。

在一步数据库操作框架sqlx里面,我们能看到这么一段代码来演示如何使用transaction。

https://github.com/launchbadge/sqlx/blob/main/examples/postgres/transaction/src/main.rshttps://github.com/launchbadge/sqlx/blob/main/examples/postgres/transaction/src/main.rs这里和Java的程序之前常用的transaction不同,这里使用了显式的Transaction对象。由于在Java中常用的多线程程序相比,异步程序又可能会在Dispatcher的线程池里面来回跳跃。所以,我们无法使用线程本地存储来存放transaction的上下文。所以,只能通过显式的变量来把事物的所有操作串起来。

async fn insert_and_verify(
    transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    test_id: i64,
) -> Result<(), Box> {
    query!(
        r#"INSERT INTO todos (id, description)
        VALUES ( $1, $2 )
        "#,
        test_id,
        "test todo"
    )
    .execute(&mut *transaction)
    .await?;

    // check that inserted todo can be fetched inside the uncommitted transaction
    let _ = query!(r#"SELECT FROM todos WHERE id = $1"#, test_id)
        .fetch_one(transaction)
        .await?;

    Ok(())
}

async fn commit_example(
    pool: &sqlx::PgPool,
    test_id: i64,
) -> Result<(), Box> {
    let mut transaction = pool.begin().await?;

    insert_and_verify(&mut transaction, test_id).await?;

    transaction.commit().await?;

    Ok(())
}

Actor模式

那ErLang很早就有的Actor模式到底解决了什么问题呢?Actor模式为什么能够成为一种应用服务器的底层方法论呢?

推荐看一下这篇文章。

Actor 分布式并行计算模型: The Actor Model for Concurrent Computation_禅与计算机程序设计艺术的博客-CSDN博客

[TBD]

超越进程

对于软件架构,我们这篇的讨论已经有不少内容了。但是在当前一切资源池化,容器化,甚至Serverless 等一些框架和概念之下。可能会引入更加宏观的资源编排方式。涉及到的是系统架构级别的内容,我们以后有机会再讨论吧。

Kubernetes, Hadoop Yarn, Apache Mesos这些 server farm级别的资源管理编排工具都在不同的领域使用。计算机行业的魅力所在,就在于限制我们的主要是我们的思想。

参考文献:

【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例_安陵容的博客-CSDN博客_java线程不安全的例子

【Linux 进程间通信(IPC)详解 · 第一篇】进程间通信(IPC)基本概念_idiot5lie的博客-CSDN博客

多核编程4种方式:多线程、多进程、csp(轻量级线程)、actor(轻量级进程)_zfoo-framework的博客-CSDN博客_多核编程

Actix : Rust 中 Actor 模型的实现 | Yanick's Blog

未完,持续更新,欢迎点评

你可能感兴趣的:(软件架构,软件构建)