2023年10月

背景

我们应对并发场景时一般会采用下面方式去预估线程池的线程数量,比如QPS需求是1000,平均每个任务需要执行的时间是t秒,那么我们需要的线程数是t * 1000。

但是在一些情况下,这个t是不好估算的,即便是估算出来了,在实际的线程环境上也需要进行验证和微调。比如在本文所阐述分页查询的数据项组合场景中。

1、数据组合依赖不同的上游接接口, 它们的响应时间参差不齐,甚至差距还非常大。有些接口支持批量查询而另一些则不支持批量查询。有些接口因为性能问题还需要考虑降级和平滑方案。

2、为了提升用户体验,这里的查询设计了动态列,因此每一次访问所需要组合的数据项和数量也是不同的。

因此这里如果需要估算出一个合理的t是不太现实的。

方案

一种可动态调节的策略,根据监控的反馈对线程池进行微调。整体设计分为
装配逻辑

线程池封装
设计。

1、装配逻辑

查询结果,拆分分片(水平拆分),并行装配(垂直拆分),获得装配项列表(动态列), 并行装配每一项。

2、线程池封装

可调节的核心线程数、最大线程数、线程保持时间,队列大小,提交任务重试等待时间,提交任务重试次数。 固定异常拒绝策略。

调节参数:

字段 名称 说明
corePoolSize 核心线程数 参考线程池定义
maximumPoolSize 最大线程数 参考线程池定义
keepAliveTime 线程存活时间 参考线程池定义
queueSize 队列长度 参考线程池定义
resubmitSleepMillis 提交任务重试等待时间 添加任务被拒绝后重试时的等待时间
resubmitTimes 提交任务重试次数 添加任务被拒绝后重试添加的最大次数
    @Data
	private static class PoolPolicy {

		/** 核心线程数 */
		private Integer corePoolSize;

		/** 最大线程数 */
		private Integer maximumPoolSize;

		/** 线程存活时间 */
		private Integer keepAliveTime;

		/** 队列容量 */
		private Integer queueSize;

		/** 重试等待时间 */
		private Long resubmitSleepMillis;

		/** 重试次数 */
		private Integer resubmitTimes;
	}

创建线程池:

线程池的创建考虑了动态的需求,满足根据压测结果进行微调的要求。首先缓存旧的线程池后再创建新的线程,当新的线程池创建成功后再去关闭旧的线程池。保证在这个替换过程中不影响正在执行的业务。线程池使用了中断策略,用户可以及时感知到系统繁忙并保证了系统资源占用的安全。

public void reloadThreadPool(PoolPolicy poolPolicy) {
    if (poolPolicy == null) {
        throw new RuntimeException("The thread pool policy cannot be empty.");
    }
    if (poolPolicy.getCorePoolSize() == null) {
        poolPolicy.setCorePoolSize(0);
    }
    if (poolPolicy.getMaximumPoolSize() == null) {
        poolPolicy.setMaximumPoolSize(Runtime.getRuntime().availableProcessors() + 1);
    }
    if (poolPolicy.getKeepAliveTime() == null) {
        poolPolicy.setKeepAliveTime(60);
    }
    if (poolPolicy.getQueueSize() == null) {
        poolPolicy.setQueueSize(Runtime.getRuntime().availableProcessors() + 1);
    }
    if (poolPolicy.getResubmitSleepMillis() == null) {
        poolPolicy.setResubmitSleepMillis(200L);
    }
    if (poolPolicy.getResubmitTimes() == null) {
        poolPolicy.setResubmitTimes(5);
    }
    // - 线程池策略没有变化直接返回已有线程池。
    ExecutorService original = this.executorService;
    this.executorService = new ThreadPoolExecutor(
            poolPolicy.getCorePoolSize(),
            poolPolicy.getMaximumPoolSize(),
            poolPolicy.getKeepAliveTime(), TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(poolPolicy.getQueueSize()),
            new ThreadFactoryBuilder().setNameFormat(threadNamePrefix + "-%d").setDaemon(true).build(),
            new ThreadPoolExecutor.AbortPolicy());
    this.poolPolicy = poolPolicy;
    if (original != null) {
        original.shutdownNow();
    }
}

任务提交:

线程池封装对象中使用的线程池拒绝策略是AbortPolicy,因此在线程数和阻塞队列到达上限后会触发异常。另外在这里为了保证提交的成功率利用重试策略实现了一定程度的延迟处理,具体场景中可以结合业务特点进行适当的调节和配置。

public <T> Future<T> submit(Callable<T> task) {
    RejectedExecutionException exception = null;
    Future<T> future = null;
    for (int i = 0; i < this.poolPolicy.getResubmitTimes(); i++) {
        try {
            // - 添加任务
            future = this.executorService.submit(task);
            exception = null;
            break;
        } catch (RejectedExecutionException e) {
            exception = e;
            this.theadSleep(this.poolPolicy.getResubmitSleepMillis());
        }
    }
    if (exception != null) {
        throw exception;
    }
    return future;
}

监控:

1、submit提交的监控

见代码中的「监控点①」,在submit方法中添加监控点,监控key的需要添线程池封装对象的线程名称前缀,用于区分具体的线程池对象。

「监控点①」用于监控添加任务的动作是否正常,以便对线程池对象及策略参数进行微调。

public <T> Future<T> submit(Callable<T> task) {
    // - 监控点①
    CallerInfo callerInfo = Profiler.registerInfo(UmpConstant.THREAD_POOL_WAP + threadNamePrefix,
                UmpConstant.APP_NAME,
                UmpConstant.UMP_DISABLE_HEART,
                UmpConstant.UMP_ENABLE_TP);
    RejectedExecutionException exception = null;
    Future<T> future = null;
    for (int i = 0; i < this.poolPolicy.getResubmitTimes(); i++) {
        try {
            // - 添加任务
            future = this.executorService.submit(task);
            exception = null;
            break;
        } catch (RejectedExecutionException e) {
            exception = e;
            this.theadSleep(this.poolPolicy.getResubmitSleepMillis());
        }
    }
    if (exception != null) {
        // - 监控点①
        Profiler.functionError(callerInfo);
        throw exception;
    }
    // - 监控点①
    Profiler.registerInfoEnd(callerInfo);
    return future;
}

2、线程池并行任务

见代码的「监控点②」,分别在添加任务和任务完成后。

「监控点②」实时统计在线程中执行的总任务数量,用于评估线程池的任务的数量的满载水平。

/** 任务并行数量统计 */
private AtomicInteger parallelTaskCount = new AtomicInteger(0);

public <T> Future<T> submit(Callable<T> task) {
    RejectedExecutionException exception = null;
    Future<T> future = null;
    for (int i = 0; i < this.poolPolicy.getResubmitTimes(); i++) {
        try {
            // - 添加任务
            future = this.executorService.submit(()-> {
                T rst = task.call();
                // - 监控点②
                log.info("{} - Parallel task count {}", this.threadNamePrefix,  this.parallelTaskCount.decrementAndGet());
                return rst;
            });
            // - 监控点②
            log.info("{} + Parallel task count {}", this.threadNamePrefix,  this.parallelTaskCount.incrementAndGet());
            exception = null;
            break;
        } catch (RejectedExecutionException e) {
            exception = e;
            this.theadSleep(this.poolPolicy.getResubmitSleepMillis());
        }
    }
    if (exception != null) {
        throw exception;
    }
    return future;
}

3、调节

线程池封装对象策略的调节时机

1)上线前基于流量预估的压测阶段;

2)上线后跟进监控数据和线程池中任务的满载水平进行人工微调,也可以通过JOB在指定的时间自动调整;

3)大促前依据往期大促峰值来调高相关参数。

线程池封装对象策略的调节经验

1)访问时长要求较低时,我们可以考虑调小线程数和阻塞队列,适当调大提交任务重试等待时间和次数,以便降低资源占用。

2)访问时长要求较高时,就需要调大线程数并保证相对较小的阻塞队列,调小提交任务的重试等待时间和次数甚至分别调成0和1(即关闭重试提交逻辑)。

作者:京东零售 王文明

来源:京东云开发者社区 转载请注明来源

C++语言支持分离编译,在多文件编程中:
变量或函数可以被声明多次,但却只能被定义一次。如果要在多个文件中使用同一个变量,变量的定义能且只能出现在一个文件中,在其他使用该变量的文件中需要声明该变量
。如果想声明一个变量而非定义它,就在前面加上关键字extern,并且不能显示初始化变量:

//a.cpp
int j = 5;    //定义j并初始化//b.cpp
extern int j;    //声明j/*使用j...*/

//以下声明形式是错误的://extern int j = 5;

上面的例子中,在b.cpp中,a.cpp里面定义的j是可见的。于是我们对j的使用不会出现错误。

constexpr是C++11标准引入的,constexpr是一种类似于const的常量类型,只能使用常量表达式对constexpr的变量进行初始化,并且编译器在编译时会对表达式是否是常量进行检查,确保用于初始化constexpr变量的表达式的值一定不会在运行时是未知的。对于const和constexpr(C++11)的变量来说,它们默认是
内部链接
的,也就是说它们只在定义它们的文件中可见。比如:

//a.cpp
const int i = 5;
constexpr
int j = 10;//b.cpp extern const int i; //错误,i不可见 extern constexpr int j; //错误,j不可见

在上面的例子中,a.cpp中定义的i和j在b.cpp中是不可见的,b中对于i和j的声明代码会被编译器认为是想要
定义
常量i和j但却没有初始化,由于const和constexpr在定义时必须初始化,因此编译器会报错。我们可以在定义const变量是加入extern关键字,来指出这个const变量是外部链接的:

//a.cpp
extern const int i = 5;extern constexpr int j = 10;//b.cpp
extern const int i;    //正确,i是外部链接的
extern constexpr int j;    //错误,不能指定constexpr为外部链接

这里多说一句:变量会在定义时申请存储空间,因此不能使用不完整类型定义变量,而只能使用不完整类型声明变量。

wmproxy

wmproxy
将用
Rust
实现
http/https
代理,
socks5
代理, 反向代理, 静态文件服务器,后续将实现
websocket
代理, 内外网穿透等, 会将实现过程分享出来, 感兴趣的可以一起造个轮子法

项目 ++wmproxy++

gite: https://gitee.com/tickbh/wmproxy

github: https://github.com/tickbh/wmproxy

了解三种格式

Json

  • JSON是一种轻量级的数据交换格式,被广泛使用在Web应用程序之间传输数据。
  • JSON使用大括号{}来表示数据结构,使用冒号:来连接键和值。
  • JSON支持字符串、数字、布尔值、null、数组和对象等多种数据类型。
  • JSON文件通常用于数据交换、存储等场景,也可以用作配置文件。
    JSON简单易读存储通用,但JSON原生不支持注释用来做配置文件比较硬伤。

它流行度极高,基本上每个程序员都和他打过交道。
多层级时,对齐和缩进不好控制,容易出错

Yaml

  • YAML被设计为一种可读性极强的数据序列化标准,可以用来表达层次化数据。
  • YAML使用空格缩进来表示数据层次结构。
  • YAML支持浮点数、布尔值、字符串、数组、映射等多种数据类型。
  • YAML文件通常用于配置文件、数据交换等场景。

与JSON及TOML相比,结构比较紧凑
但相对用空格缩近,编写及拷贝时出错的机率比JSON及TOML高许多

Toml

TOML 旨在成为一个语义明显且易于阅读的最小化配置文件格式。
TOML 应该能很容易地被解析成各种语言中的数据结构。

  • TOML是一种简洁明了的键值对格式,被设计成可以很容易地映射为哈希表。
  • TOML使用等号(=)来连接键和值,使用缩进来表示数据层次结构。
  • TOML支持整数、浮点数、字符串、布尔值、数组、字典等多种数据类型。
  • TOML文件通常用于配置文件、数据交换等场景。


易于阅读和编写

语法灵活
与JSON配置相比,TOML在简洁性方面远远胜出;
与YAML配置相比,TOML在简洁性以及语法灵活性方面远远胜出。

三种格式测试数据的对比

我们用Rust的项目配置文件来做对比,为了展示所有的类型,格式有所变更。它以Toml来做配置文件,我们首先先展示toml的格式

内容包含创建者,创建时间,项目名称,项目依赖等信息,如果我们将其转化成可配置的JSON格式时

toml

create="tickbh"
create_time=2023-09-08T10:30:00Z
[project]
# 项目名称
name="wmproxy"
version="1.1"
editor=2022
# 项目依赖
[project.dependencies]
wenmeng={version = "0.1.21", default-features = false, features = ["std", "tokio"]}
webparse={version = "0.1", default-features = false}

行数12行,注释两行,全部顶格开头,原生支持时间格式

json

{
  "create": "tickbh",
  "create_time": "2023-09-08T10:30:00.000Z",
  "project": {
    "name": "wmproxy",
    "version": "1.1",
    "editor": 2022,
    "dependencies": {
      "wenmeng": {
        "version": "0.1.21",
        "default-features": false,
        "features": [
          "std",
          "tokio"
        ]
      },
      "webparse": {
        "version": "0.1",
        "default-features": false
      }
    }
  }
}

行数23行,层次的递进比较多,不容易对齐,无法注释,不支持时间格式

yaml

create: tickbh
create_time: 2023-09-08T10:30:00.000Z
project:
  # 项目名称
  name: wmproxy
  version: "1.1"
  editor: 2022
  # 项目依赖
  dependencies:
    wenmeng:
      version: 0.1.21
      default-features: false
      features:
        - std
        - tokio
    webparse:
      version: "0.1"
      default-features: false

行数18行,注释两行,原生支持时间格式,到
features
这级行,深度相对较高,但是一眼看上去比
json
清晰

相对来说JSON比较不适合做比较复杂的配置文件,但
VSCODE
使用的拓展的JSON以支持注释功能。

接下来尝试将nginx.conf格式做转换

以下尝试的将

http {
    gzip on;
    server {
        listen 80;  #监听80的服务端口
        server_name wm-proxy.com;  #监听的域名
       
        location /products {
            proxy_pass http://127.0.0.1:8090/proxy;
            add_header 'Access-Control-Allow-Credentials' 'true';
            add_header 'Access-Control-Allow-Origin' '*';
        }
        
        location / {
            root wmproxy;
            index index.html index.htm;
        }
    }
}

我们也模仿类似的结构,但是对于
toml
,
yaml
,
json
来说,都没有一个key两个值的,要么我们只能用对应的数组,此时我来先来初步重构类似的结构。以下我们以toml结构为例,我们分析table的级数有三级,最外层为
http
,中间层为
server
为数组,最内层为
location
也为数组,
headers
我们用之前提到过的
mappings
,用
proxy
开头来表示重写
Reqeust
,其它的来表示重写
Response
,文件系统我们用上节提到的
file_server

我们先定义
http
的table,他只有一个属性gzip为on

[http]
gzip="on"

其次
server
为一个数组,那么我们可以如下定义,有绑定地址和server_name

[[http.server]]
bind_addr="127.0.0.1:80"
server_name="wm-proxy.com"

再然后
location
也为一个数组,定义如下

[[http.server.location]]
rule = "/products"
reverse_proxy = "http://127.0.0.1:8090/proxy"
headers = [
  ["+", "Access-Control-Allow-Credentials", "true"],
  ["+", "Access-Control-Allow-Origin", "*"]
]
[[http.server.location]]
rule = "/"
file_server = { root="wmproxy", browse = true, index=["index.html", "index.htm"] }

那么,最终的结构为如下:

[http]
gzip="on"
[[http.server]]
bind_addr="127.0.0.1:80"
server_name="wm-proxy.com"
[[http.server.location]]
rule = "/products"
reverse_proxy = "http://127.0.0.1:8090/proxy"
headers = [
  ["+", "Access-Control-Allow-Credentials", "true"],
  ["+", "Access-Control-Allow-Origin", "*"]
]
[[http.server.location]]
rule = "/"
file_server = { root="wmproxy", browse = true, index=["index.html", "index.htm"] }

而yaml的格式结构如下:

http:
  gzip: on
  server:
    - bind_addr: 127.0.0.1:80
      server_name: wm-proxy.com
      location:
        - rule: /products
          reverse_proxy: http://127.0.0.1:8090/proxy
          headers:
            - - +
              - Access-Control-Allow-Credentials
              - "true"
            - - +
              - Access-Control-Allow-Origin
              - "*"
        - rule: /
          file_server:
            root: wmproxy
            browse: true
            index:
              - index.html
              - index.htm

而json的格式结构如下:

{
  "http": {
    "gzip": "on",
    "server": [
      {
        "bind_addr": "127.0.0.1:80",
        "server_name": "wm-proxy.com",
        "location": [
          {
            "rule": "/products",
            "reverse_proxy": "http://127.0.0.1:8090/proxy",
            "headers": [
              [
                "+",
                "Access-Control-Allow-Credentials",
                "true"
              ],
              [
                "+",
                "Access-Control-Allow-Origin",
                "*"
              ]
            ]
          },
          {
            "rule": "/",
            "file_server": {
              "root": "wmproxy",
              "browse": true,
              "index": [
                "index.html",
                "index.htm"
              ]
            }
          }
        ]
      }
    ]
  }
}
  • 自建的好处是比较清晰,可以自定义自己合适的结构,但是编写者需要重新开始学习,而用能用的配置文件需要遵循它的格式定义
  • 像toml文件,如果层级很深,他的key值配置会很长,因为他一旦定义一个table,就是从最顶级来解析,但是编写者只要熟悉过这配置文件很快就能写出满意的配置文件
  • 而yaml的层级结构相对会需要去看对齐与否,编写的时候需要额外注意,因为弄错了缩进,层级就会发生错误
  • 而json最后结尾的会有相当多的花括号,相对比较容易弄错。JSON总体来说不太适合做比较复杂的配置文件

结语

在不考虑自建格式的情况下,如nginx的
nginx.conf
,如caddy的
Caddyfile
,将会同时兼容
toml

yaml
格式的配置文件。

端口扫描是一种网络安全测试技术,该技术可用于确定对端主机中开放的服务,从而在渗透中实现信息搜集,其主要原理是通过发送一系列的网络请求来探测特定主机上开放的
TCP/IP
端口。具体来说,端口扫描程序将从指定的起始端口开始,向目标主机发送一条
TCP

UDP
消息(这取决于端口的协议类型)。如果目标主机正在监听该端口,则它将返回一个确认消息,这表明该端口是开放的。如果没有响应,则说明该端口是关闭的或被过滤。

首先我们来了解一下阻塞与非阻塞模式:

  • 阻塞模式是指当I/O操作无法立即完成时,应用程序会阻塞并等待操作完成。例如,在使用阻塞套接字接收数据时,如果没有数据可用,则调用函数将一直阻塞,直到有数据可用为止。在这种模式下,I/O操作将会一直阻塞应用程序的进程,因此无法执行其他任务。

  • 非阻塞模式是指当I/O操作无法立即完成时,应用程序会立即返回并继续执行其他任务。例如,在使用非阻塞套接字接收数据时,如果没有数据可用,则调用函数将立即返回,并指示操作正在进行中,同时应用程序可以执行其他任务。在这种模式下,应用程序必须反复调用I/O操作以检查其完译状态,这通常是通过轮询或事件通知机制实现的。非阻塞模式允许应用程序同时执行多个任务,但每个I/O操作都需要增加一定的额外开销。

要实现端口探测我们可以通过
connect()
这个函数来实现,利用
connect
函数实现端口开放检查的原理是通过
TCP
协议的三次握手过程来探测目标主机是否开放目标端口。


TCP
协议的三次握手过程中,客户端向服务器发送一个
SYN
标志位的
TCP
数据包。如果目标主机开放了目标端口并且正在监听连接请求,则服务器会返回一个带有
SYN

ACK
标志位的
TCP
数据包,表示确认连接请求并请求客户端确认。此时客户端回应一个
ACK
标志位的
TCP
数据包,表示确认连接请求,并建立了一个到服务器端口的连接。此时客户端和服务器端之间建立了一个
TCP
连接,可以进行数据传输。

如果目标主机没有开放目标端口或者目标端口已经被占用,则服务器不会响应客户端的
TCP
数据包,客户端会在一定时间后收到一个超时错误,表示连接失败。

因此,通过调用
connect
函数,可以向目标主机发送一个
SYN
标志位的
TCP
数据包并等待服务器响应,从而判断目标端口是否开放。如果
connect
函数返回0,则表示连接成功,目标端口开放;否则,连接失败,目标端口未开放或目标主机不可达。

// 探测网络端口开放情况
BOOL PortScan(char *Addr, int Port)
{
  WSADATA wsd;
  SOCKET sHost;
  SOCKADDR_IN servAddr;

  // 初始化套接字库
  if (WSAStartup(MAKEWORD(2, 2), &wsd) != 0)
  {
    return FALSE;
  }

  // 创建套接字
  sHost = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
  if (INVALID_SOCKET == sHost)
  {
    return FALSE;
  }

  // 设置连接地址和端口
  servAddr.sin_family = AF_INET;
  servAddr.sin_addr.S_un.S_addr = inet_addr(Addr);
  servAddr.sin_port = htons(Port);

  // 连接测试
  int retval = connect(sHost, (LPSOCKADDR)&servAddr, sizeof(servAddr));
  if (retval != SOCKET_ERROR)
  {
    return TRUE;
  }

  WSACleanup();
  closesocket(sHost);
  return FALSE;
}

int main(int argc, char* argv[])
{
  int port_list[] = { 80, 443, 445, 135, 139, 445 };
  int port_size = sizeof(port_list) / sizeof(int);

  for (int x = 0; x < port_size; x++)
  {
    int ret = PortScan("8.141.58.64", port_list[x]);
    printf("循环次数: %d 端口: %d 状态: %d \n", x + 1, port_list[x], ret);
  }

  system("pause");
  return 0;
}

上述代码片段则是一个简单的端口探测案例,当运行后程序会调用
connect
函数向目标主机发送一个
SYN
标志位的
TCP
数据包,探测目标端口是否开放。如果目标主机响应带有
SYN

ACK
标志位的
TCP
数据包,则表示连接请求成功并请求确认,操作系统在自动发送带
ACK
标志位的
TCP
数据包进行确认,建立
TCP
连接;

如果目标主机没有响应或者响应带有
RST
标志位的
TCP
数据包,则表示连接请求失败,目标端口为未开放状态。通过此方式,程序可以快速检测多个端口是否开放,该程序运行后输出效果如下图所示;

上述代码虽然可以实现端口扫描,但是读者应该会发现此方法扫描很慢,这是因为扫描器每次只能链接一个主机上的端口只有当
connect
函数返回后才会执行下一次探测任务,而如果需要提高扫描效率那么最好的方法是采用非阻塞的扫描模式,使用非阻塞模式我们可以在不使用多线程的情况下提高扫描速度。

非阻塞模式所依赖的核心函数为
select()
函数是一种用于多路
I/O
复用的系统调用,在
Windows
中提供了对该系统调用的支持。
select()
函数可以同时监听多个文件或套接字(
socket
)的可读、可写和出错状态,并返回有状态变化的文件或套接字的数量,在使用该函数时读者应率先调用
ioctlsocket()
函数,并设置
FIONBIO
套接字为非阻塞模式。

select 函数的基本语法如下:

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

参数解释:

  • nfds:需要监听的文件或套接字最大编号加1
  • readfds:可读文件或套接字集合
  • writefds:可写文件或套接字集合
  • exceptfds:出错文件或套接字集合
  • timeout:超时时间,如果为NULL,则表示一直等待直到有事件发生

select 函数会阻塞进程,直到在需要监听的文件或套接字中有一个或多个文件或套接字发送了需要监听的事件,或者超时时间到达。当
select()
函数返回时,可以通过
fd_set
集合来查询有状态变化的文件或套接字。

select 函数的原理是将调用进程的文件或套接字加入内核监测队列,等待事件发生。当某个文件或套接字有事件发生时,内核会将其添加到内核缓冲区中,同时在返回时告诉进程有哪些套接字可以进行
I/O
操作,进程再根据文件或套接字的状态进行相应的处理。使用
select()
函数可以大大提高
I/O
操作的效率,减少资源占用。

如下代码实现的是一段简单的端口扫描程序,用于检查目标主机的一段端口范围内是否有端口处于开放状态。该函数中通过设置
fd_set
类型的掩码(
mask
)并加入套接字,使用select()函数查询该套接字的可写状态,并设置超时时间为1毫秒,如果返回值为0,则目标端口未开放,继续下一个端口的扫描。如果返回值为正数,则目标端口已成功连接(开放),输出扫描结果并继续下一个端口的扫描。

该代码中使用了非阻塞套接字和
select()
函数的组合来实现非阻塞IO。非阻塞套接字可以使程序不会在等待数据到来时一直阻塞,而是可以在等待数据到来的同时进行其他操作,从而提高程序的效率。
select()
函数则可以同时等待多个套接字的数据到来,从而使程序更加高效地进行
I/O
操作。

// 非阻塞端口探测
void PortScan(char *address, int StartPort, int EndPort)
{
  SOCKADDR_IN ServAddr;
  TIMEVAL TimeOut;
  FD_SET mask;

  TimeOut.tv_sec = 0;

  // 设置超时时间为500毫秒
  TimeOut.tv_usec = 1000;
  // 指定模式
  unsigned long mode = 1;

  // 循环扫描端口
  for (int port = StartPort; port <= EndPort; port++)
  {
    SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    ServAddr.sin_family = AF_INET;
    ServAddr.sin_addr.S_un.S_addr = inet_addr(address);
    ServAddr.sin_port = htons(port);

    FD_ZERO(&mask);
    FD_SET(sock, &mask);

    // 设置为非阻塞模式
    ioctlsocket(sock, FIONBIO, &mode);
    connect(sock, (struct sockaddr *)&ServAddr, sizeof(ServAddr));

    // 查询可写入状态 如果不为0则说明这个端口是开放的
    int ret = select(0, 0, &mask, 0, &TimeOut);
    if (ret != 0 && ret != -1)
    {
      printf("扫描地址: %-13s --> 端口: %-5d --> 状态: [Open] \n", address, port);
    }
    else
    {
      printf("扫描地址: %-13s --> 端口: %-5d --> 状态: [Close] \n", address, port);
    }
  }
}

int main(int argc, char *argv[])
{
  char *Addr[2] = { "192.168.1.1", "192.168.1.10" };

  WSADATA wsa;
  if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0)
  {
    exit(0);
  }

  for (int x = 0; x < 2; x++)
  {
    PortScan(Addr[x], 1, 255);
  }

  WSACleanup();

  system("pause");
  return 0;
}

读者可自行编译并运行上述代码片段,默认会扫描
Addr[2]
数组内的两个IP地址的
1-255
端口范围开放情况,读者可感觉到效率上变得快了许多,输出效果如下图所示;

上述代码虽然增加的扫描速度但是还可以进一步优化,我们可以通过增加信号机制,通过使用信号可以很好的控制扫描并发连接数,增加了线程控制将会使扫描器更加稳定,同时我们还引用了多线程模式,通过两者的结合可以极大的提高扫描质量和效率。

基于信号的端口扫描,也称为异步
IO
端口扫描,是一种高效的端口扫描技术,可以利用操作系统的信号机制提高网络
I/O
的效率。基于信号的端口扫描具有非阻塞和异步的特性,可以最大限度地提高网络I/O效率,同时在大并发量下表现出更好的性能。但是,使用时需要小心处理信号的相关问题,避免死锁和数据不一致。

#include <stdio.h>
#include <winsock2.h>

#pragma comment (lib, "ws2_32")

typedef struct _THREAD_PARAM
{
  char *HostAddr;             // 扫描主机
  DWORD dwStartPort;          // 端口号
  HANDLE hEvent;              // 事件句柄
  HANDLE hSemaphore;          // 信号量句柄
}THREAD_PARAM;

// 最大线程数,用于控制信号量数量
#define MAX_THREAD 10

// 线程扫描函数
DWORD WINAPI ScanThread(LPVOID lpParam)
{
  // 拷贝传递来的扫描参数
  THREAD_PARAM ScanParam = { 0 };
  MoveMemory(&ScanParam, lpParam, sizeof(THREAD_PARAM));

  // 设置信号
  SetEvent(ScanParam.hEvent);

  WSADATA wsa;
  WSAStartup(MAKEWORD(2, 2), &wsa);

  // 初始化套接字
  SOCKET s = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
  sockaddr_in sockaddr;

  // 填充扫描地址与端口
  sockaddr.sin_family = AF_INET;
  sockaddr.sin_addr.S_un.S_addr = inet_addr(ScanParam.HostAddr);
  sockaddr.sin_port = htons(ScanParam.dwStartPort);

  // 开始连接
  if (connect(s, (SOCKADDR*)&sockaddr, sizeof(SOCKADDR)) == 0)
  {
    printf("地址: %-16s --> 端口: %-5d --> 信号量: %-5d 状态: [Open] \n",
      ScanParam.HostAddr, ScanParam.dwStartPort, ScanParam.hSemaphore);
  }
  else
  {
    printf("地址: %-16s --> 端口: %-5d --> 信号量: %-5d 状态: [Close] \n",
      ScanParam.HostAddr, ScanParam.dwStartPort, ScanParam.hSemaphore);
  }

  closesocket(s);
  WSACleanup();

  // 释放一个信号量
  ReleaseSemaphore(ScanParam.hSemaphore, 1, NULL);
  return 0;
}

int main(int argc, char *argv[])
{
  // 线程参数传递
  THREAD_PARAM ThreadParam = { 0 };

  // 设置线程信号
  SetEvent(ThreadParam.hEvent);

  // 创建事件
  HANDLE hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);

  // 创建信号
  HANDLE hSemaphore = CreateSemaphore(NULL, MAX_THREAD, MAX_THREAD, NULL);

  ThreadParam.hEvent = hEvent;
  ThreadParam.hSemaphore = hSemaphore;
  ThreadParam.HostAddr = "59.110.117.109";

  for (DWORD port = 1; port < 4096; port++)
  {
    // 判断信号量
    DWORD dwWaitRet = WaitForSingleObject(hSemaphore, 200);
    if (dwWaitRet == WAIT_OBJECT_0)
    {
      ThreadParam.dwStartPort = port;

      // 启动扫描线程
      HANDLE hThread = CreateThread(NULL, 0, ScanThread, (LPVOID)&ThreadParam, 0, NULL);

      // 等待事件
      WaitForSingleObject(hEvent, INFINITE);

      // 重置信号
      ResetEvent(hEvent);
    }
    else if (dwWaitRet == WAIT_TIMEOUT)
    {
      continue;
    }
  }

  system("pause");
  return 0;
}

读者可自行编译并运行上述代码,将对特定IP地址进行端口探测,每次启用10个线程,即实现了控制线程并发,又实现了端口多线程扫描效果,如下图所示;

本文作者: 王瑞
本文链接:
https://www.lyshark.com/post/e9090338.html
版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

计算机的基本硬件组成

image

CPU被比喻为人类大脑,其作用是指挥和控制人体的各项功能。而内存和硬盘则相当于大脑中的记忆板块,用于记录和存储信息。主板则类似于人的神经系统,起到连接和协调人体各个部分的作用。显卡则类似于人的眼睛,负责显示图像和视频。而计算机的电源则类似于人的心脏,只有启动电源才能进行各种活动。

image

CPU

CPU,全称为中央处理单元 (Central Processing Unit),是电脑中最重要的组件之一,可以说是电脑的核心。它扮演着让电脑真正成为计算机的角色。CPU 就像人的大脑一样,是电脑的智慧和计算能力的源泉。CPU 主要负责执行计算机程序和应用程序中的指令。这个过程可以分为三个关键阶段,即指令提取、解码和执行。首先,CPU 从主存中提取指令,然后解码这些指令的实际内容,最后由 CPU 的各个部分执行这些指令。这样,CPU 就能够实现各种计算和操作,从而驱动整个计算机系统的运行。

内存

内存(Memory),作为计算机中至关重要的组件之一,扮演着程序与CPU之间的桥梁。它充当着计算机系统中所有程序运行的平台,因此内存对计算机的性能和运行效果有着巨大的影响。内存也被称为主存,其主要功能是存储CPU需要进行运算的数据,同时也用于与硬盘等外部存储设备进行数据的交换。在计算机运行过程中,CPU会将需要运算的数据调入主存中进行计算,待计算完成后,CPU再将结果传送出来。因此,主存的运行状态直接决定了计算机的稳定运行。可以说,内存在计算机系统中扮演着举足轻重的角色,是计算机正常运行所必不可少的部分。

主板

主板(Motherboard)是计算机系统中的核心组件之一,它集成了各种电子元件、插槽和接口等,为CPU、内存和各种功能卡(如声卡、网卡等)提供了安装插槽,同时也为各种多媒体和通信设备提供了接口。主板的作用类似于人体的神经系统,将计算机系统中的各个部分连接起来,实现数据的传递和通信。

需要注意的是,不同的主板所支持的CPU、内存等硬件规格也是不同的,因此选择合适的主板对于构建一台高性能的计算机至关重要。一般来说,主板会标明支持的CPU型号和插槽类型,以及支持的内存频率和容量等信息。因此,在选择主板时,需要根据自己的需求和所使用的硬件规格进行匹配,以确保主板能够正常运行并提供最佳性能。

下图展示了主板的结构图,可以清晰地看到主板上的各个组件、插槽和接口的布局。这些组件的合理设计和布局,能够为计算机系统的稳定运行和扩展性提供良好的支持。
image

北桥芯片:在主板芯片组中,北桥芯片扮演着至关重要的角色。它是主板中最重要的芯片之一,负责控制CPU、内存和显卡等核心组件的工作。因此,北桥芯片的性能直接影响着主板的整体性能表现。为了保证北桥芯片在高负载情况下的稳定工作,一般会在其上方安装散热片,以降低其工作时产生的热量。

image

南桥芯片:南桥芯片是主板芯片组中的第二大芯片,它主要负责控制输入/输出设备和外部设备。南桥芯片承担着管理和控制USB设备、IDE设备、SATA设备、音频控制器、键盘控制器、实时时钟控制器和高级电源管理等设备的功能。它为这些设备提供了稳定的连接和数据传输,确保计算机系统的正常运行和外部设备的兼容性。

image

主板是一个配件,它拥有各种各样的插槽,有时候甚至多达数十乃至上百个。在主板上,我们需要将 CPU 和内存插入其中。为了解决 CPU 和内存之间的通信问题,主板的芯片组和总线发挥了重要作用。芯片组负责控制数据传输的流向,即确定数据从哪里到哪里。而总线则充当着实际数据传输的高速公路。因此,总线速度(Bus Speed)直接影响着数据传输的快慢程度。

系统总线和IO总线

一般来说,主机板上的芯片组分为北桥和南桥。北桥是主要的系统总线,负责连接CPU、内存和显卡等主要组件,因此其传输速度较快。而南桥则是输入输出(I/O)总线,主要负责连接硬盘、USB、网络卡等外围设备,其中最常见的是PCI总线。这两条总线通过桥接芯片或电路相互连接。

image

可以用一个形象的例子来解释,就好比一个城市中有两条主干道,一条属于行政区,一条属于商业区,而中间有一个环岛将这两条主干道连接在一起。系统总线就好比行政区的主干道,而I/O总线则类似于商业区的主干道。虽然系统总线和I/O总线的带宽都以Gbyte为单位衡量,但显而易见的是,行政区的主干道相对于商业区的主干道来说更为核心、更宽阔、更顺畅,因此对于设计要求也更高。

CPU总线的功能

CPU总线,也被称为FSB(前端总线,Front Side Bus),是PC系统中速度最快的总线,也是芯片组和主板的核心。通常,总线可以分为三类:数据总线,地址总线和控制总线,这对于CPU总线也适用。在微型机中,CPU作为总线的主控,通过控制总线向各个部件发送控制信号,通过地址总线指定需要访问的部件,如存储器,数据总线则用于传送数据信息。数据总线是双向的,即数据信息可以由CPU发送至其他部件(写),也可以由其他部件发送至CPU(读)。CPU总线位于芯片组和CPU之间,负责CPU与外界所有部件的通信,因为CPU是通过芯片组来连接各个部件的。此外,CPU总线还负责CPU与Cache之间的通信。就像之前提到的,CPU总线就像是一条主干道,数据和信号从这条主干道上流向各个部件和外部设备,同时也从各个部件流回CPU(主要是数据)。

image

I/O 设备

在拥有了主板、CPU和内存这三大核心组件,并接上电源供电后,计算机基本上就可以开始运行了。然而,此时还缺少各种输入/输出(I/O)设备,这些设备对于计算机的正常运作来说是至关重要的。

如果你使用的是个人电脑,那么显示器是必不可少的。只有通过显示器,我们才能看到计算机输出的各种图像和文字,因此显示器被称为输出设备。

同样地,鼠标和键盘也是不可或缺的配件。它们是我们输入文本和执行各种操作的工具,例如写下这篇文章。因此,鼠标和键盘被称为输入设备。

显卡

除了显示器、鼠标和键盘之外,还有一个非常特殊的设备,那就是显卡(Graphics Card)。在如今使用图形界面操作系统的计算机中,无论是Windows、Mac OS还是Linux,显卡都是必不可少的。有人可能会说,在组装计算机时没有购买独立的显卡,计算机仍然可以正常运行!这是因为现在的主板通常都内置了显卡。然而,如果你使用计算机进行游戏、图形渲染或深度学习等应用,那么很可能需要购买一块独立的显卡,并将其插入主板上。显卡之所以特殊,是因为它内部除了CPU之外,还有另一个处理器,即GPU(Graphics Processing Unit,图形处理器)。GPU能够执行各种计算任务,使其在游戏、图形渲染或深度学习等方面发挥重要作用。

冯若依曼和哈佛

冯·诺依曼体系结构概述

冯·诺依曼提出了计算机制造的三个基本原则,即采用二进制、程序存储和顺序执行。他还提出了计算机的五个组成部分,包括运算器、控制器、存储器、输入设备和输出设备。这套理论被称为冯·诺依曼体系结构,而根据这一原理制造的计算机则被称为冯·诺依曼结构计算机。

冯·诺依曼最早提出了程序存储的概念,并成功地将其应用于计算机的设计中。冯·诺依曼体系结构成为现代计算机的基础,至今大多数计算机仍然采用冯·诺依曼计算机的组织结构。因此,冯·诺依曼被誉为“现代计算机之父”。他的贡献为计算机科学的发展奠定了坚实的基础。

image

冯·诺依曼体系结构的特点包括:

  1. 计算机处理的数据和指令都使用二进制数表示。
  2. 指令和数据混合存储在同一个存储器(如硬盘)中,没有区别对待。
  3. 程序的指令按照顺序依次执行。

为了实现冯·诺依曼体系结构,计算机必须具备以下功能:

  1. 能够将所需的程序和数据输入到计算机中进行处理。
  2. 具备长期存储程序、数据、中间结果和最终运算结果的能力,通常通过硬盘来实现。
  3. 能够执行各种算术、逻辑运算和数据传输等数据处理操作,这需要具备算术逻辑单元(ALU)。
  4. 能够根据需要控制程序的执行流程,并根据指令协调机器的各个部件进行操作。
  5. 能够按照要求将处理的结果输出给用户。

冯·诺依曼体系结构为计算机的设计和运行提供了基本原则和指导,使得计算机能够高效地处理数据和执行程序。这一体系结构的影响深远,至今仍然是现代计算机的基础。

冯·诺依曼体系工作原理(CPU工作原理)

程序的执行过程实际上是一个不断取出指令、分析指令、执行指令的循环。冯·诺依曼型计算机采用了串行顺序处理的工作机制,即使相关数据已经准备好,也必须逐条执行指令序列。如下图所示:

image

具体的执行过程如下:

  1. 预先将指令序列(即程序)和原始数据输入到计算机内存中,每条指令都明确规定了计算机从哪个地址取数,进行什么操作,然后将结果送到何处等步骤。
  2. 在执行过程中,计算机首先从内存中取出第一条指令,通过控制器的译码器接收指令要求,再从存储器中获取数据进行指定的运算和逻辑操作,然后按照指定的地址将结果送回内存。如果需要将数据存储到硬盘等存储设备中,还需要将内存中的数据存储到硬盘中。接下来,计算机取出第二条指令,在控制器的指导下完成规定的操作,依次进行下去,直到遇到停止指令。
  3. 在计算机中,基本上有两种信息在流动。一种是数据,包括各种原始数据、中间结果和程序等;另一种信息是控制信息,它控制计算机的各个部件执行指令规定的各种操作。

这种串行顺序处理的工作机制使冯·诺依曼型计算机能够高效地执行程序,并完成各种计算和数据处理任务。控制器起着关键的作用,根据指令的要求来控制计算机的各个部件,使其协调工作。整个过程中,数据和控制信息在计算机内部不断传递和交换,实现了计算机的功能。

image

image

哈佛结构

哈佛结构是一种存储器结构,将程序指令存储和数据存储分开。其主要特点是将程序和数据存储在不同的存储空间中,即程序存储器和数据存储器是两个独立的存储器。每个存储器都有独立的编址和访问方式,这样可以减轻程序运行时的访存瓶颈。

在哈佛结构中,程序存储器和数据存储器可以同时访问,提高了数据的读取和写入速度。由于程序和数据存储器是分开的,因此程序的指令和数据可以同时传输到CPU,这样可以提高计算机的并行性和执行效率。

image

image

哈佛结构和冯诺依曼结构区别

哈佛结构和冯诺依曼结构的主要区别在于它们是否区分指令和数据。在实际情况中,指令和数据存储在内存中的同一位置。然而,在CPU内部的缓存中,仍然会区分指令缓存和数据缓存,因此在执行时,指令和数据会从两个不同的地方获取。在CPU的外部,通常采用冯诺依曼模型,而在CPU的内部使用哈佛结构。大多数的DSP没有缓存,因此直接采用哈佛结构。虽然哈佛结构设计复杂,但效率较高,而冯诺依曼结构相对简单但速度较慢。为了提高处理速度,CPU制造商在CPU内部增加了高速缓存,同时基于相同的目的,区分了指令缓存和数据缓存。

总结

计算机的基本硬件组成包括中央处理器(CPU)、内存、主板、输入/输出设备和显卡等。中央处理器是计算机的核心,负责执行计算机程序和应用程序中的指令。内存用于存储和交换数据,对计算机的性能和运行效果具有重要影响。主板起到连接和协调计算机各个部件的作用。输入/输出设备包括显示器、鼠标和键盘等,用于输入和输出数据。显卡负责显示图像和视频。如果有条件,我认为自行组装一台电脑会提供更高的性价比,并且可以通过实践学习计算机的构成。