Explanation of IO,NIO and AIO of IO stream

Keywords: Java

catalogue

1 IO,NIO,AIO

1.1 basic concepts

The relevant methods of Java I/O are as follows:

  • Synchronize and block (I/O method):
    The server implementation mode starts a thread for a connection. Each thread handles the I/O itself and waits for the I/O until it is completed, that is, when the client has a connection request, the server needs to start a thread for processing. However, if the connection does not do anything, it will cause unnecessary thread overhead. Of course, this disadvantage can be improved through the thread pool mechanism.
    The limitation of I/O is that it is a stream oriented, blocking and serial process. The Socket connection I/O of each client needs a thread to process, and during this period, this thread is occupied until the Socket is closed. During this period, TCP connection, data reading and data return are blocked. In other words, a lot of CPU time slices and memory resources occupied by threads are wasted during this period. In addition, each time a Socket connection is established, a new thread is created to communicate with the Socket separately (in a blocking manner). This method has fast response speed and simple control. It is very effective when the number of connections is small, but if a thread is generated for each connection, it is undoubtedly a waste of system resources. If the number of connections is large, there will be insufficient resources;
  • Synchronous non blocking (NIO method):
    The server implementation mode is to start a thread for one request, and each thread handles the I/O itself. However, other threads poll to check whether the I/O is ready. There is no need to wait for the I/O to complete, that is, the connection requests sent by the client will be registered on the multiplexer, and the multiplexer will start a thread for processing when it turns to inquire about the connection of I/O requests. NIO is buffer oriented, non blocking and selector based. It uses a thread to poll and monitor multiple data transmission channels, and processes which channel is ready (i.e. there is a set of data that can be processed). The server side saves a Socket connection list, and then polls the list. If it finds data readable on a Socket port, it calls the corresponding read operation of the Socket connection; If it is found that there is data writable on a Socket port, call the corresponding write operation of the Socket connection; If the Socket connection of a port has been interrupted, call the corresponding destructor method to close the port. This can make full use of server resources and greatly improve the efficiency;
  • Asynchronous non blocking (AIO method, JDK7 release):
    The server implementation mode is to start a thread with a valid request. The operating system completes the I/O request of the client first, and then notifies the server application to start the thread for processing. Each thread does not have to handle the I/O in person, but delegates the operating system to handle it, and does not need to wait for the I/O to complete. If the I/O is completed, the operating system will notify otherwise. This mode adopts the epoll model of Linux.

1.2 NIO details

In the case of few connections, the traditional I/O mode is easier to write and easier to use. However, with the increasing number of connections, each connection of traditional I/O processing needs to consume a thread, and the efficiency of the program increases with the increase of the number of threads when the number of threads is small, but decreases with the increase of the number of threads after a certain number. Therefore, the bottleneck of traditional blocking I/O is that it can not handle too many connections.
The purpose of non blocking I/O is to solve this bottleneck. The number of threads that non blocking IO processes connections is not related to the number of connections. For example, the system processes 10000 connections. Non blocking I/O does not need to start 10000 threads. You can use 1000 or 2000 threads to process. Because non blocking IO processing connections are asynchronous, when a connection sends a request to the server, the server regards the connection request as a request event and assigns the event to the corresponding function for processing. We can put this processing function into the thread to execute, and return the thread after execution, so that a thread can process multiple events asynchronously. Most of the time of blocked I/O threads is wasted waiting for requests

I/O NIO
Flow oriented Buffer oriented
Blocking IO Non blocking IO
nothing selector

NIO is based on blocks. It processes data in blocks. In NIO, the two most important components are buffer and channel. Buffer is a continuous memory block, which is the transit place for NIO to read and write data. Channel identifies the source or destination of buffered data. It is used to read or write data to the buffer. It is an interface to access the buffer. Channel is a two-way channel, which can be read or written. Stream is unidirectional. The application cannot directly read and write to the channel, but must do so through the buffer, that is, the channel reads and writes data through the buffer.
The following four steps are generally followed to read and write data using Buffer:

  • Write data to Buffer;
  • Call the flip() method;
  • Read data from Buffer;
  • Call the clear() method or the compact() method.

When data is written to the Buffer, the Buffer will record how much data is written. Once you want to read data, you need to switch the Buffer from write mode to read mode through the flip() method. In the read mode, all data previously written to the Buffer can be read.
Once all the data has been read, the buffer needs to be emptied so that it can be written again. There are two ways to empty the buffer: call the clear() or compact() method. The clear() method empties the entire buffer. The compact() method will only clear the data that has been read. Any unread data is moved to the beginning of the buffer, and the newly written data will be placed after the unread data in the buffer.
There are many types of buffers. Different buffers provide different ways to operate the data in the Buffer

1.2.1 Buffer read / write data

There are two situations when Buffer writes data:

  • Write from the Channel to the Buffer. For example, the Channel reads data from the file and writes it to the Channel;
  • Directly call the put method to write data into it.

There are two ways to read data from the Buffer:

  • Read data from Buffer to Channel;
  • Use the get() method to read data from the Buffer.

The rewin method of Buffer sets position back to 0, so all data in Buffer can be re read. The limit remains unchanged and still indicates how many elements (byte, char, etc.) can be read from the Buffer.

1.2.2 Buffer and clear methods

clear() and compact() methods
Once the data in the Buffer is read, the Buffer needs to be ready to be written again. This can be done through the clear() or compact() methods.
If the clear() method is called, position will be set back to 0 and limit will be set to the value of capacity. In other words, the Buffer is cleared. The data in the Buffer is not cleared, but these marks tell us where to start writing data to the Buffer.
If there is some unread data in the Buffer, call the clear() method, and the data will be forgotten, which means that there are no more tags to tell you which data has been read and which has not yet been read. If there is still unread data in the Buffer and these data are needed later, but you want to write some data first, use the compact() method. The compact() method copies all unread data to the beginning of the Buffer. Then set position directly after the last unread element. The limit attribute is still set to capacity like the clear() method. Now Buffer is ready to write data, but it will not overwrite unread data.

1.2.3 Buffer parameters

Buffer has three important parameters: position, capacity and limit.
capacity refers to the size of the Buffer, which has been determined when the Buffer is created.
limit when Buffer is in write mode, it refers to how much data can be written; In read mode, it refers to how much data can be read.
Position when Buffer is in write mode, it refers to the position of the next write data; In read mode, the location of the data currently to be read. position+1 means that limit and position have different meanings when reading / writing data in Buffer. When calling the flip method of Buffer and changing from write mode to read mode, limit = position (write), position (read) = 0.

1.2.4 scattering & aggregation

NIO provides methods for processing structured data, which are called scattering and gathering.
Scattering refers to reading data into a set of buffers, not just one. Aggregation, on the contrary, refers to writing data to a set of buffers.
The basic usage of scattering and aggregation is quite similar to that of a single Buffer operation. In scatter reading, the channels fill each Buffer in turn. When one Buffer is filled, it begins to fill the next. In a sense, the Buffer array is like a large Buffer. When the specific structure of the file is known, several buffers conforming to the file structure can be constructed so that the size of each Buffer just conforms to the size of each segment structure of the file. At this time, the contents can be assembled into each corresponding Buffer at one time by scattering reading, so as to simplify the operation. If you need to create a file with a specified format, you can quickly create a file by constructing a Buffer object of appropriate size and using aggregate writing.

1.3 Java AIO

AIO related classes and interfaces:

  • java.nio.channels.AsynchronousChannel: mark a Channel to support asynchronous IO operations;
  • Java.nio.channels.asynchronous serversocketchannel: AIO version of ServerSocket, creating TCP server, binding address, listening port, etc;
  • java.nio.channels.AsynchronousSocketChannel: stream oriented asynchronous Socket Channel, representing a connection;
  • java.nio.channels.AsynchronousChannelGroup: group management of asynchronous channels for resource sharing. An asynchronous Channel group is bound to a thread pool, which performs two tasks: Processing IO events and dispatching CompletionHandler. When creating an AsynchronousServerSocketChannel, you can pass in an AsynchronousChannelGroup. Then the asynchronoussocketchannels created through the AsynchronousServerSocketChannel will belong to the same group and share resources;
  • java.nio.channels.CompletionHandler: callback interface for asynchronous IO operation results, which is used to define callback work after IO operation is completed. The API of AIO allows two ways to process the results of asynchronous operations: the returned Future mode or registering CompletionHandler. It is recommended to use CompletionHandler. The calls of these handlers are distributed by the thread pool of AsynchronousChannelGroup. Here, the size of the thread pool is a key factor in performance.

1.4 use examples

1.4.1 scattering aggregation

Using scatter and aggregation to read and write structured files

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;


public class NIOScatteringandGathering {
 public void createFiles(String TPATH){
 try {
 ByteBuffer bookBuf = ByteBuffer.wrap("java Performance optimization techniques".getBytes("utf-8"));
ByteBuffer autBuf = ByteBuffer.wrap("test".getBytes("utf-8"));
int booklen = bookBuf.limit();
int autlen = autBuf.limit();
ByteBuffer[] bufs = new ByteBuffer[]{bookBuf,autBuf};
File file = new File(TPATH);
if(!file.exists()){
try {
file.createNewFile();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
try {
FileOutputStream fos = new FileOutputStream(file);
FileChannel fc = fos.getChannel();
fc.write(bufs);
fos.close();
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

ByteBuffer b1 = ByteBuffer.allocate(booklen);
ByteBuffer b2 = ByteBuffer.allocate(autlen);
ByteBuffer[] bufs1 = new ByteBuffer[]{b1,b2};
File file1 = new File(TPATH);
try {
FileInputStream fis = new FileInputStream(file);
FileChannel fc = fis.getChannel();
fc.read(bufs1);
String bookname = new String(bufs1[0].array(),"utf-8");
String autname = new String(bufs1[1].array(),"utf-8");
System.out.println(bookname+" "+autname);
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

} catch (UnsupportedEncodingException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
 
 }
 
 public static void main(String[] args){
 NIOScatteringandGathering nio = new NIOScatteringandGathering();
 nio.createFiles("C:\\1.TXT");
 }
}

1.4.2 comparison test of three I / O modes

The following code compares the performance of traditional I/O, Byte based NIO and memory mapping based NIO, and uses the time-consuming of reading and writing operations of a file with 4 million data as the evaluation basis.

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.IntBuffer;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

public class NIOComparator {
 public void IOMethod(String TPATH){
 long start = System.currentTimeMillis();
 try {
DataOutputStream dos = new DataOutputStream(
 new BufferedOutputStream(new FileOutputStream(new File(TPATH))));
for(int i=0;i<4000000;i++){
dos.writeInt(i);//Write 4000000 integers
}
if(dos!=null){
dos.close();
}
 } catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
 } catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
 }
 long end = System.currentTimeMillis();
 System.out.println(end - start);
 start = System.currentTimeMillis();
 try {
DataInputStream dis = new DataInputStream(
 new BufferedInputStream(new FileInputStream(new File(TPATH))));
for(int i=0;i<4000000;i++){
dis.readInt();
}
if(dis!=null){
dis.close();
}
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
 
 end = System.currentTimeMillis();
 System.out.println(end - start);
 }
 
 public void ByteMethod(String TPATH){
 long start = System.currentTimeMillis();
 try {
FileOutputStream fout = new FileOutputStream(new File(TPATH));
FileChannel fc = fout.getChannel();//Get file channel
ByteBuffer byteBuffer = ByteBuffer.allocate(4000000*4);//Allocate Buffer
for(int i=0;i<4000000;i++){
byteBuffer.put(int2byte(i));//Convert integer to array
}
byteBuffer.flip();//Ready to write
fc.write(byteBuffer);
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
 long end = System.currentTimeMillis();
 System.out.println(end - start);
 
 start = System.currentTimeMillis();
 FileInputStream fin;
try {
fin = new FileInputStream(new File(TPATH));
FileChannel fc = fin.getChannel();//Get file channel
ByteBuffer byteBuffer = ByteBuffer.allocate(4000000*4);//Allocate Buffer
fc.read(byteBuffer);//Read file data
fc.close();
byteBuffer.flip();//Ready to read data
while(byteBuffer.hasRemaining()){
byte2int(byteBuffer.get(),byteBuffer.get(),byteBuffer.get(),byteBuffer.get());//Convert byte to integer
}
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
 end = System.currentTimeMillis();
 System.out.println(end - start);
 }
 
 public void mapMethod(String TPATH){
 long start = System.currentTimeMillis();
 //Method of mapping files directly to memory
 try {
FileChannel fc = new RandomAccessFile(TPATH,"rw").getChannel();
IntBuffer ib = fc.map(FileChannel.MapMode.READ_WRITE, 0, 4000000*4).asIntBuffer();
for(int i=0;i<4000000;i++){
ib.put(i);
}
if(fc!=null){
fc.close();
}
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
 long end = System.currentTimeMillis();
 System.out.println(end - start);
 
 start = System.currentTimeMillis();
 try {
FileChannel fc = new FileInputStream(TPATH).getChannel();
MappedByteBuffer lib = fc.map(FileChannel.MapMode.READ_ONLY, 0, fc.size());
lib.asIntBuffer();
while(lib.hasRemaining()){
lib.get();
}
if(fc!=null){
fc.close();
}
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
 end = System.currentTimeMillis();
 System.out.println(end - start);
 
 }
 
 public static byte[] int2byte(int res){
 byte[] targets = new byte[4];
 targets[3] = (byte)(res & 0xff);//Lowest order
 targets[2] = (byte)((res>>8)&0xff);//Sub low
 targets[1] = (byte)((res>>16)&0xff);//Secondary high
 targets[0] = (byte)((res>>>24));//Highest bit, unsigned right shift
 return targets;
 }
 
 public static int byte2int(byte b1,byte b2,byte b3,byte b4){
 return ((b1 & 0xff)<<24)|((b2 & 0xff)<<16)|((b3 & 0xff)<<8)|(b4 & 0xff);
 }
 
 public static void main(String[] args){
 NIOComparator nio = new NIOComparator();
 nio.IOMethod("c:\\1.txt");
 nio.ByteMethod("c:\\2.txt");
 nio.ByteMethod("c:\\3.txt");
 }
}

1.4.3 DirectBuffer VS ByteBuffer

NIO's Buffer also provides a DirectBuffer class that can directly access the system's physical memory. DirectBuffer inherits from ByteBuffer, but is different from ordinary ByteBuffer.
Ordinary ByteBuffer still allocates space on the JVM heap, and its maximum memory is limited by the maximum heap, while DirectBuffer allocates directly on physical memory and does not occupy heap space. When accessing ordinary ByteBuffer, the system will always use a kernel buffer for indirect operation. The location of DirectrBuffer is equivalent to the kernel buffer.
Therefore, using DirectBuffer is a method closer to the bottom of the system. Therefore, it is faster than ordinary ByteBuffer. Compared with ByteBuffer, DirectBuffer has much faster read and write access speed, but the cost of creating and destroying DirectrBuffer is higher than that of ByteBuffer. The code for comparing DirectBuffer with ByteBuffer is as follows

import java.nio.ByteBuffer;


public class DirectBuffervsByteBuffer {
 public void DirectBufferPerform(){
 long start = System.currentTimeMillis();
 ByteBuffer bb = ByteBuffer.allocateDirect(500);//Allocate DirectBuffer
 for(int i=0;i<100000;i++){
 for(int j=0;j<99;j++){
 bb.putInt(j);
 }
 bb.flip();
 for(int j=0;j<99;j++){
 bb.getInt(j);
 }
 }
 bb.clear();
 long end = System.currentTimeMillis();
 System.out.println(end-start);
 start = System.currentTimeMillis();
 for(int i=0;i<20000;i++){
 ByteBuffer b = ByteBuffer.allocateDirect(10000);//Create DirectBuffer
 }
 end = System.currentTimeMillis();
 System.out.println(end-start);
 }
 
 public void ByteBufferPerform(){
 long start = System.currentTimeMillis();
 ByteBuffer bb = ByteBuffer.allocate(500);//Allocate DirectBuffer
 for(int i=0;i<100000;i++){
 for(int j=0;j<99;j++){
 bb.putInt(j);
 }
 bb.flip();
 for(int j=0;j<99;j++){
 bb.getInt(j);
 }
 }
 bb.clear();
 long end = System.currentTimeMillis();
 System.out.println(end-start);
 start = System.currentTimeMillis();
 for(int i=0;i<20000;i++){
 ByteBuffer b = ByteBuffer.allocate(10000);//Create ByteBuffer
 }
 end = System.currentTimeMillis();
 System.out.println(end-start);
 }
 
 public static void main(String[] args){
 DirectBuffervsByteBuffer db = new DirectBuffervsByteBuffer();
 db.ByteBufferPerform();
 db.DirectBufferPerform();
 }
}
Operation results
920
110
531
390

It can be seen that the cost of frequently creating and destroying directbuffers is much greater than allocating memory space on the heap.
Use the parameter - XX:MaxDirectMemorySize=200M – Xmx200M to configure the maximum DirectBuffer and maximum heap space in VM Arguments. 200M space is requested in the code respectively. If the set heap space is too small, such as 1M, an error will be thrown, as shown below

Error occurred during initialization of VM
Too small initial heap for new size specified

1.4.4 monitoring code for DirectBuffer

DirectBuffer information will not be printed in GC, because GC only records the memory recovery of heap space. It can be seen that because Byte Buffer allocates space on the heap, its GC arrays are relatively frequent. When buffers need to be created frequently, DirectBuffer should not be used because the code to create and destroy DirectBuffer is relatively expensive. However, if the DirectBuffer can be reused, the system performance can be greatly improved

import java.lang.reflect.Field;


public class monDirectBuffer {
 
public static void main(String[] args){
try {
Class c = Class.forName("java.nio.Bits");//Get private data through reflection
Field maxMemory = c.getDeclaredField("maxMemory");
maxMemory.setAccessible(true);
Field reservedMemory = c.getDeclaredField("reservedMemory");
reservedMemory.setAccessible(true);
synchronized(c){
Long maxMemoryValue = (Long)maxMemory.get(null);
Long reservedMemoryValue = (Long)reservedMemory.get(null);
System.out.println("maxMemoryValue="+maxMemoryValue);
System.out.println("reservedMemoryValue="+reservedMemoryValue);
}
} catch (ClassNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (SecurityException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (NoSuchFieldException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IllegalArgumentException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IllegalAccessException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

}
}
Run output
maxMemoryValue=67108864
reservedMemoryValue=0

Because NIO is difficult to use, many companies have launched their own frameworks to encapsulate JDK NIO, such as Apache Mina, JBoss netty, Sun Grizzly, etc. these frameworks directly encapsulate the TCP or UDP protocol of the transport layer. Netty is only a NIO framework, which does not need additional support of the Web container, that is, it does not limit the Web container

1.4.5 AIO application examples

Server

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousServerSocketChannel;
import java.nio.channels.AsynchronousSocketChannel;
import java.nio.channels.CompletionHandler;
import java.util.concurrent.ExecutionException;

public class SimpleServer {
public SimpleServer(int port) throws IOException { 
final AsynchronousServerSocketChannel listener = 
 AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(port));
//Listen to the message and start the Handle processing module after receiving it
listener.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {
public void completed(AsynchronousSocketChannel ch, Void att) { 
listener.accept(null, this);// Accept next connection 
handle(ch);// Process current connection 
}

@Override
public void failed(Throwable exc, Void attachment) {
// TODO Auto-generated method stub

} 

});
}

public void handle(AsynchronousSocketChannel ch) { 
ByteBuffer byteBuffer = ByteBuffer.allocate(32);//Open a Buffer 
try { 
 ch.read(byteBuffer).get();//Read input 
} catch (InterruptedException e) { 
 // TODO Auto-generated catch block 
 e.printStackTrace(); 
} catch (ExecutionException e) { 
 // TODO Auto-generated catch block 
 e.printStackTrace(); 
} 
byteBuffer.flip(); 
System.out.println(byteBuffer.get()); 
// Do something 
} 

}

Client program

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousSocketChannel;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;

public class SimpleClientClass {
private AsynchronousSocketChannel client; 
public SimpleClientClass(String host, int port) throws IOException, 
                                    InterruptedException, ExecutionException { 
 this.client = AsynchronousSocketChannel.open(); 
 Future<?> future = client.connect(new InetSocketAddress(host, port)); 
 future.get(); 
} 

public void write(byte b) { 
 ByteBuffer byteBuffer = ByteBuffer.allocate(32);
 System.out.println("byteBuffer="+byteBuffer);
 byteBuffer.put(b);//Write the read character to the buffer 
 byteBuffer.flip();
 System.out.println("byteBuffer="+byteBuffer);
 client.write(byteBuffer); 
} 

}

Main function

import java.io.IOException;
import java.util.concurrent.ExecutionException;

import org.junit.Test;

public class AIODemoTest {

@Test
public void testServer() throws IOException, InterruptedException { 
 SimpleServer server = new SimpleServer(9021); 
 Thread.sleep(10000);//Because it is asynchronous operation, sleep for a certain time to avoid the program ending soon
} 

@Test 
public void testClient() throws IOException, InterruptedException, ExecutionException { 
SimpleClientClass client = new SimpleClientClass("localhost", 9021); 
 client.write((byte) 11); 
}

public static void main(String[] args){
AIODemoTest demoTest = new AIODemoTest();
try {
demoTest.testServer();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
try {
demoTest.testClient();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (ExecutionException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}

}

Posted by GBS on Sat, 30 Oct 2021 22:23:32 -0700